diff --git a/README.md b/README.md index 6ed59958..5011d645 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ npm install npm run dev ``` -4. Open [http://localhost:8080](http://localhost:8080) in your browser. +4. Open [http://localhost:3000](http://localhost:3000) in your browser. ## Contributors diff --git a/components/editor/action-bar.tsx b/components/editor/action-bar.tsx new file mode 100644 index 00000000..47c7684c --- /dev/null +++ b/components/editor/action-bar.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEditorStore } from "@/store/editor-store"; +import { Button } from "../ui/button"; +import { FileCode, RefreshCw, Code, Moon, Sun } from "lucide-react"; +import CssImportDialog from "./css-import-dialog"; +import { useState } from "react"; +import { parseCssInput } from "@/utils/parse-css-input"; +import { toast } from "../ui/use-toast"; +import { CodePanelDialog } from "./code-panel-dialog"; +import { Separator } from "../ui/separator"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import { useTheme } from "../theme-provider"; +import ContrastChecker from "./contrast-checker"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; + +export function ActionBar() { + const { + themeState, + resetToCurrentPreset, + setThemeState, + hasCurrentPresetChanged, + } = useEditorStore(); + const [cssImportOpen, setCssImportOpen] = useState(false); + const [codePanelOpen, setCodePanelOpen] = useState(false); + + const handleCssImport = (css: string) => { + const { lightColors, darkColors } = parseCssInput(css); + const styles = { + ...themeState.styles, + light: { ...themeState.styles.light, ...lightColors }, + dark: { ...themeState.styles.dark, ...darkColors }, + }; + + setThemeState({ + ...themeState, + styles, + }); + + toast({ + title: "CSS imported", + description: "Your custom CSS has been imported successfully", + }); + }; + + const { theme, toggleTheme } = useTheme(); + + const handleThemeToggle = (event: React.MouseEvent) => { + const { clientX: x, clientY: y } = event; + toggleTheme({ x, y }); + }; + + return ( +
+
+
+
+ + + + + {theme === "dark" ? ( + + ) : ( + + )} + + + + Toggle light/dark mode + +
+ + + + + + + Import CSS variables + + + + + + Reset to preset defaults + + + + + + + + View theme code + +
+
+ + + +
+ ); +} diff --git a/components/editor/code-panel-dialog.tsx b/components/editor/code-panel-dialog.tsx new file mode 100644 index 00000000..0fcaa207 --- /dev/null +++ b/components/editor/code-panel-dialog.tsx @@ -0,0 +1,25 @@ +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import CodePanel from "./code-panel"; +import { ThemeEditorState } from "@/types/editor"; + +interface CodePanelDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + themeEditorState: ThemeEditorState; +} + +export function CodePanelDialog({ + open, + onOpenChange, + themeEditorState, +}: CodePanelDialogProps) { + return ( + + +
+ +
+
+
+ ); +} diff --git a/components/editor/code-panel.tsx b/components/editor/code-panel.tsx index 63fc5850..c74ccc66 100644 --- a/components/editor/code-panel.tsx +++ b/components/editor/code-panel.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Button } from "@/components/ui/button"; -import { Copy, Check, PanelRight } from "lucide-react"; +import { Copy, Check } from "lucide-react"; import { ThemeEditorState } from "@/types/editor"; import { ScrollArea, ScrollBar } from "../ui/scroll-area"; import { ColorFormat } from "../../types"; @@ -14,21 +14,13 @@ import { import { usePostHog } from "posthog-js/react"; import { useEditorStore } from "@/store/editor-store"; import { usePreferencesStore } from "@/store/preferences-store"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { generateThemeCode } from "@/utils/theme-style-generator"; + interface CodePanelProps { themeEditorState: ThemeEditorState; - onCodePanelToggle: () => void; } -const CodePanel: React.FC = ({ - themeEditorState, - onCodePanelToggle, -}) => { +const CodePanel: React.FC = ({ themeEditorState }) => { const [registryCopied, setRegistryCopied] = useState(false); const [copied, setCopied] = useState(false); const posthog = usePostHog(); @@ -102,20 +94,7 @@ const CodePanel: React.FC = ({
-

Code

- - - - - Collapse Code Panel - +

Theme Code

{preset && preset !== "default" && (
diff --git a/components/editor/color-picker.tsx b/components/editor/color-picker.tsx index a64875bc..76e9d27c 100644 --- a/components/editor/color-picker.tsx +++ b/components/editor/color-picker.tsx @@ -40,11 +40,10 @@ const ColorPicker = ({ color, onChange, label }: ColorPickerProps) => { > {label} -
{localColor}
-
+
setIsOpen(!isOpen)} > @@ -60,7 +59,7 @@ const ColorPicker = ({ color, onChange, label }: ColorPickerProps) => { type="text" value={localColor} onChange={handleColorChange} - className="flex-1 h-8 px-2 text-sm rounded-md border bg-background" + className="flex-1 h-8 px-2 text-sm rounded bg-input/25 border border-border/20" />
diff --git a/components/editor/contrast-checker.tsx b/components/editor/contrast-checker.tsx new file mode 100644 index 00000000..52946bc1 --- /dev/null +++ b/components/editor/contrast-checker.tsx @@ -0,0 +1,407 @@ +import React, { useState } from "react"; +import { useContrastChecker } from "../../hooks/use-contrast-checker"; +import { ThemeStyleProps } from "@/types/theme"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogDescription, +} from "../ui/dialog"; +import { Contrast, Check, AlertTriangle, Info, Moon, Sun } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Card, CardContent } from "../ui/card"; +import { Badge } from "../ui/badge"; +import { ScrollArea } from "../ui/scroll-area"; +import { Separator } from "../ui/separator"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { useTheme } from "@/components/theme-provider"; + +type ContrastCheckerProps = { + currentStyles: ThemeStyleProps; +}; + +const MIN_CONTRAST_RATIO = 4.5; + +type ColorCategory = "content" | "interactive" | "functional"; + +type ColorPair = { + id: string; + foregroundId: keyof ThemeStyleProps; + backgroundId: keyof ThemeStyleProps; + foreground: string | undefined; + background: string | undefined; + label: string; + category: ColorCategory; +}; + +const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => { + const [filter, setFilter] = useState<"all" | "issues">("all"); + const { theme, toggleTheme } = useTheme(); + + const colorPairsToCheck: ColorPair[] = [ + // Content - Base, background, cards, containers + { + id: "base", + foregroundId: "foreground", + backgroundId: "background", + foreground: currentStyles?.["foreground"], + background: currentStyles?.["background"], + label: "Base", + category: "content", + }, + { + id: "card", + foregroundId: "card-foreground", + backgroundId: "card", + foreground: currentStyles?.["card-foreground"], + background: currentStyles?.["card"], + label: "Card", + category: "content", + }, + { + id: "popover", + foregroundId: "popover-foreground", + backgroundId: "popover", + foreground: currentStyles?.["popover-foreground"], + background: currentStyles?.["popover"], + label: "Popover", + category: "content", + }, + { + id: "muted", + foregroundId: "muted-foreground", + backgroundId: "muted", + foreground: currentStyles?.["muted-foreground"], + background: currentStyles?.["muted"], + label: "Muted", + category: "content", + }, + + // Interactive - Buttons, links, actions + { + id: "primary", + foregroundId: "primary-foreground", + backgroundId: "primary", + foreground: currentStyles?.["primary-foreground"], + background: currentStyles?.["primary"], + label: "Primary", + category: "interactive", + }, + { + id: "secondary", + foregroundId: "secondary-foreground", + backgroundId: "secondary", + foreground: currentStyles?.["secondary-foreground"], + background: currentStyles?.["secondary"], + label: "Secondary", + category: "interactive", + }, + { + id: "accent", + foregroundId: "accent-foreground", + backgroundId: "accent", + foreground: currentStyles?.["accent-foreground"], + background: currentStyles?.["accent"], + label: "Accent", + category: "interactive", + }, + + // Functional - Sidebar, destructive, special purposes + { + id: "destructive", + foregroundId: "destructive-foreground", + backgroundId: "destructive", + foreground: currentStyles?.["destructive-foreground"], + background: currentStyles?.["destructive"], + label: "Destructive", + category: "functional", + }, + { + id: "sidebar", + foregroundId: "sidebar-foreground", + backgroundId: "sidebar", + foreground: currentStyles?.["sidebar-foreground"], + background: currentStyles?.["sidebar"], + label: "Sidebar Base", + category: "functional", + }, + { + id: "sidebar-primary", + foregroundId: "sidebar-primary-foreground", + backgroundId: "sidebar-primary", + foreground: currentStyles?.["sidebar-primary-foreground"], + background: currentStyles?.["sidebar-primary"], + label: "Sidebar Primary", + category: "functional", + }, + { + id: "sidebar-accent", + foregroundId: "sidebar-accent-foreground", + backgroundId: "sidebar-accent", + foreground: currentStyles?.["sidebar-accent-foreground"], + background: currentStyles?.["sidebar-accent"], + label: "Sidebar Accent", + category: "functional", + }, + ]; + + const validColorPairsToCheck = colorPairsToCheck.filter( + (pair): pair is ColorPair & { foreground: string; background: string } => + !!pair.foreground && !!pair.background + ); + const contrastResults = useContrastChecker(validColorPairsToCheck); + + const getContrastResult = (pairId: string) => { + return contrastResults?.find((res) => res.id === pairId); + }; + + const totalIssues = contrastResults?.filter( + (result) => result.contrastRatio < MIN_CONTRAST_RATIO + ).length; + + const filteredPairs = + filter === "all" + ? colorPairsToCheck + : colorPairsToCheck.filter((pair) => { + const result = getContrastResult(pair.id); + return result && result.contrastRatio < MIN_CONTRAST_RATIO; + }); + + // Group color pairs by category + const categoryLabels: Record = { + content: "Content & Containers", + interactive: "Interactive Elements", + functional: "Navigation & Functional", + }; + + const categories: ColorCategory[] = ["content", "interactive", "functional"]; + const groupedPairs = categories + .map((category) => ({ + category, + label: categoryLabels[category], + pairs: filteredPairs.filter((pair) => pair.category === category), + })) + .filter((group) => group.pairs.length > 0); + + return ( + + + + + + Check contrast accessibility + + + + + +
+
+ + Contrast Checker + + + WCAG 2.0 AA requires a contrast ratio of at least{" "} + {MIN_CONTRAST_RATIO}:1{" • "} + + Learn more + + +
+
+ + + + + +

Toggle theme

+
+
+ + +
+
+
+ + +
+ {groupedPairs.map((group) => ( +
+
+

{group.label}

+ +
+
+ {group.pairs.map((pair) => { + const result = getContrastResult(pair.id); + const isValid = + result?.contrastRatio !== undefined && + result?.contrastRatio >= MIN_CONTRAST_RATIO; + const contrastRatio = + result?.contrastRatio?.toFixed(2) ?? "N/A"; + + return ( + + +
+

+ {pair.label} + {!isValid && ( + + )} +

+ + {isValid ? ( + <> + + {contrastRatio} + + ) : ( + <> + + {contrastRatio} + + )} + +
+ +
+
+
+
+
+ + Background + + + {pair.background} + +
+
+ +
+
+
+ + Foreground + + + {pair.foreground} + +
+
+
+ +
+ {pair.foreground && pair.background ? ( +
+

+ Aa +

+

+ Sample Text +

+
+ ) : ( +

+ Preview +

+ )} +
+
+
+
+ ); + })} +
+
+ ))} +
+
+
+
+ ); +}; + +export default ContrastChecker; diff --git a/components/editor/control-section.tsx b/components/editor/control-section.tsx index 092da979..14cdbc06 100644 --- a/components/editor/control-section.tsx +++ b/components/editor/control-section.tsx @@ -13,7 +13,10 @@ const ControlSection = ({ const [isExpanded, setIsExpanded] = useState(expanded); return ( -
+
setIsExpanded(!isExpanded)} @@ -35,7 +38,7 @@ const ControlSection = ({
{children}
diff --git a/components/editor/editor.tsx b/components/editor/editor.tsx index a014af82..ced646aa 100644 --- a/components/editor/editor.tsx +++ b/components/editor/editor.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React from "react"; import { ResizablePanelGroup, ResizablePanel, @@ -13,7 +13,6 @@ import { ThemeEditorState, } from "@/types/editor"; import { ThemeStyles } from "@/types/theme"; -import CodePanel from "./code-panel"; import { Sliders } from "lucide-react"; import { useEditorStore } from "@/store/editor-store"; @@ -36,7 +35,6 @@ const Editor: React.FC = ({ config }) => { const { themeState, setThemeState } = useEditorStore(); const Controls = config.controls; const Preview = config.preview; - const [isCodePanelOpen, setIsCodePanelOpen] = useState(true); const handleStyleChange = (newStyles: ThemeStyles) => { setThemeState({ ...themeState, styles: newStyles }); @@ -53,7 +51,7 @@ const Editor: React.FC = ({ config }) => {
-
+
= ({ config }) => {
-
- setIsCodePanelOpen(!isCodePanelOpen)} - /> +
+
- {isCodePanelOpen && ( - <> - - - setIsCodePanelOpen(!isCodePanelOpen)} - /> - - - )}
{/* Mobile Layout */}
- + Controls @@ -99,12 +81,9 @@ const Editor: React.FC = ({ config }) => { Preview - - Code - - -
+ +
= ({ config }) => { />
- -
- setIsCodePanelOpen(!isCodePanelOpen)} - /> + +
+
- - setIsCodePanelOpen(!isCodePanelOpen)} - /> -
diff --git a/components/editor/header.tsx b/components/editor/header.tsx index ee365ead..e02ee5e5 100644 --- a/components/editor/header.tsx +++ b/components/editor/header.tsx @@ -1,29 +1,21 @@ "use client"; import Link from "next/link"; -import { Moon, Sun, Heart } from "lucide-react"; +import { Heart } from "lucide-react"; import GitHubIcon from "@/assets/github.svg"; import TwitterIcon from "@/assets/twitter.svg"; import DiscordIcon from "@/assets/discord.svg"; -import { useTheme } from "@/components/theme-provider"; import Logo from "@/assets/logo.svg"; import { useGithubStars } from "@/hooks/use-github-stars"; import { SocialLink } from "@/components/social-link"; import { Separator } from "@/components/ui/separator"; -import * as SwitchPrimitives from "@radix-ui/react-switch"; export function Header() { - const { theme, toggleTheme } = useTheme(); const { stargazersCount } = useGithubStars("jnsahaj", "tweakcn"); - const handleThemeToggle = (event: React.MouseEvent) => { - const { clientX: x, clientY: y } = event; - toggleTheme({ x, y }); - }; - return (
-
+
@@ -38,7 +30,7 @@ export function Header() { {stargazersCount > 0 && stargazersCount.toLocaleString()} - +
- - - - {theme === "dark" ? ( - - ) : ( - - )} - -
diff --git a/components/editor/theme-control-panel.tsx b/components/editor/theme-control-panel.tsx index 3340f558..b01da17d 100644 --- a/components/editor/theme-control-panel.tsx +++ b/components/editor/theme-control-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React from "react"; import { ThemeEditorControlsProps, ThemeStyleProps } from "@/types/theme"; import ControlSection from "./control-section"; import ColorPicker from "./color-picker"; @@ -16,37 +16,26 @@ import { import { useEditorStore } from "../../store/editor-store"; import { Label } from "../ui/label"; import { SliderWithInput } from "./slider-with-input"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs"; +import { Tabs, TabsList, TabsContent } from "../ui/tabs"; import ThemeFontSelect from "./theme-font-select"; import { + COMMON_STYLES, DEFAULT_FONT_MONO, DEFAULT_FONT_SANS, DEFAULT_FONT_SERIF, - COMMON_STYLES, defaultThemeState, } from "../../config/theme"; import { Separator } from "../ui/separator"; import { AlertCircle } from "lucide-react"; -import CssImportDialog from "./css-import-dialog"; -import { toast } from "../ui/use-toast"; -import { parseCssInput } from "../../utils/parse-css-input"; import ShadowControl from "./shadow-control"; -import ThemeControlActions from "./theme-control-actions"; +import TabsTriggerPill from "./theme-preview/tabs-trigger-pill"; const ThemeControlPanel = ({ styles, currentMode, onChange, }: ThemeEditorControlsProps) => { - const { - applyThemePreset, - themeState, - resetToCurrentPreset, - resetToDefault, - hasDefaultThemeChanged, - hasCurrentPresetChanged, - } = useEditorStore(); - const [cssImportOpen, setCssImportOpen] = useState(false); + const { applyThemePreset, themeState } = useEditorStore(); const currentStyles = React.useMemo( () => ({ @@ -82,22 +71,6 @@ const ThemeControlPanel = ({ [onChange, styles, currentMode, currentStyles] ); - const handleCssImport = (css: string) => { - // This just shows a success toast for now - const { lightColors, darkColors } = parseCssInput(css); - onChange({ - ...styles, - light: { ...styles.light, ...lightColors }, - dark: { ...styles.dark, ...darkColors }, - }); - - // The actual CSS parsing and theme application logic would be implemented later - toast({ - title: "CSS imported", - description: "Your custom CSS has been imported successfully", - }); - }; - // Ensure we have valid styles for the current mode if (!currentStyles) { return null; // Or some fallback UI @@ -106,375 +79,368 @@ const ThemeControlPanel = ({ const radius = parseFloat(currentStyles.radius.replace("rem", "")); return ( -
-
-
-

Theme Editor

-
- setCssImportOpen(true)} - /> -
- -
+ <> +
+
+ +
+ + Colors + Typography + Other + +
- - - Colors - Typography - Other - - - - - - updateStyle("primary", color)} - label="Primary" - /> - updateStyle("primary-foreground", color)} - label="Primary Foreground" - /> - - - - updateStyle("secondary", color)} - label="Secondary" - /> - updateStyle("secondary-foreground", color)} - label="Secondary Foreground" - /> - - - - updateStyle("accent", color)} - label="Accent" - /> - updateStyle("accent-foreground", color)} - label="Accent Foreground" - /> - - - - updateStyle("background", color)} - label="Background" - /> - updateStyle("foreground", color)} - label="Foreground" - /> - - - - updateStyle("card", color)} - label="Card Background" - /> - updateStyle("card-foreground", color)} - label="Card Foreground" - /> - - - - updateStyle("popover", color)} - label="Popover Background" - /> - updateStyle("popover-foreground", color)} - label="Popover Foreground" - /> - - - - updateStyle("muted", color)} - label="Muted" - /> - updateStyle("muted-foreground", color)} - label="Muted Foreground" - /> - - - - updateStyle("destructive", color)} - label="Destructive" - /> - - updateStyle("destructive-foreground", color) - } - label="Destructive Foreground" - /> - - - - updateStyle("border", color)} - label="Border" - /> - updateStyle("input", color)} - label="Input" - /> - updateStyle("ring", color)} - label="Ring" - /> - - - - updateStyle("chart-1", color)} - label="Chart 1" - /> - updateStyle("chart-2", color)} - label="Chart 2" - /> - updateStyle("chart-3", color)} - label="Chart 3" - /> - updateStyle("chart-4", color)} - label="Chart 4" - /> - updateStyle("chart-5", color)} - label="Chart 5" - /> - - - - updateStyle("sidebar", color)} - label="Sidebar Background" - /> - updateStyle("sidebar-foreground", color)} - label="Sidebar Foreground" - /> - updateStyle("sidebar-primary", color)} - label="Sidebar Primary" - /> - - updateStyle("sidebar-primary-foreground", color) - } - label="Sidebar Primary Foreground" - /> - updateStyle("sidebar-accent", color)} - label="Sidebar Accent" - /> - - updateStyle("sidebar-accent-foreground", color) - } - label="Sidebar Accent Foreground" - /> - updateStyle("sidebar-border", color)} - label="Sidebar Border" - /> - updateStyle("sidebar-ring", color)} - label="Sidebar Ring" - /> - - - - -
- -
-

- To use custom fonts, embed them in your project.
- See{" "} - - Tailwind docs - {" "} - for details. -

-
-
- - -
- - updateStyle("font-sans", value)} + + + + updateStyle("primary", color)} + label="Primary" /> -
- - - -
- - updateStyle("font-serif", value)} + updateStyle("primary-foreground", color)} + label="Primary Foreground" /> -
+
- -
- - updateStyle("font-mono", value)} + + updateStyle("secondary", color)} + label="Secondary" /> -
- - - - - updateStyle("letter-spacing", `${value}em`) - } - min={-0.5} - max={0.5} - step={0.025} - unit="em" - label="Letter Spacing" - /> - -
- - - - updateStyle("radius", `${value}rem`)} - min={0} - max={5} - step={0.025} - unit="rem" - label="Radius" - /> - - - - updateStyle("spacing", `${value}rem`)} - min={0.15} - max={0.35} - step={0.01} - unit="rem" - label="Spacing" - /> - -
- { - if (key === "shadow-color") { - updateStyle(key, value as string); - } else if (key === "shadow-opacity") { - updateStyle(key, value.toString()); - } else { - updateStyle(key as keyof ThemeStyleProps, `${value}px`); + + updateStyle("secondary-foreground", color) } - }} - /> -
-
-
-
+ label="Secondary Foreground" + /> + - -
+ + updateStyle("accent", color)} + label="Accent" + /> + updateStyle("accent-foreground", color)} + label="Accent Foreground" + /> + + + + updateStyle("background", color)} + label="Background" + /> + updateStyle("foreground", color)} + label="Foreground" + /> + + + + updateStyle("card", color)} + label="Card Background" + /> + updateStyle("card-foreground", color)} + label="Card Foreground" + /> + + + + updateStyle("popover", color)} + label="Popover Background" + /> + updateStyle("popover-foreground", color)} + label="Popover Foreground" + /> + + + + updateStyle("muted", color)} + label="Muted" + /> + updateStyle("muted-foreground", color)} + label="Muted Foreground" + /> + + + + updateStyle("destructive", color)} + label="Destructive" + /> + + updateStyle("destructive-foreground", color) + } + label="Destructive Foreground" + /> + + + + updateStyle("border", color)} + label="Border" + /> + updateStyle("input", color)} + label="Input" + /> + updateStyle("ring", color)} + label="Ring" + /> + + + + updateStyle("chart-1", color)} + label="Chart 1" + /> + updateStyle("chart-2", color)} + label="Chart 2" + /> + updateStyle("chart-3", color)} + label="Chart 3" + /> + updateStyle("chart-4", color)} + label="Chart 4" + /> + updateStyle("chart-5", color)} + label="Chart 5" + /> + + + + updateStyle("sidebar", color)} + label="Sidebar Background" + /> + updateStyle("sidebar-foreground", color)} + label="Sidebar Foreground" + /> + updateStyle("sidebar-primary", color)} + label="Sidebar Primary" + /> + + updateStyle("sidebar-primary-foreground", color) + } + label="Sidebar Primary Foreground" + /> + updateStyle("sidebar-accent", color)} + label="Sidebar Accent" + /> + + updateStyle("sidebar-accent-foreground", color) + } + label="Sidebar Accent Foreground" + /> + updateStyle("sidebar-border", color)} + label="Sidebar Border" + /> + updateStyle("sidebar-ring", color)} + label="Sidebar Ring" + /> + + + + +
+ +
+

+ To use custom fonts, embed them in your project.
+ See{" "} + + Tailwind docs + {" "} + for details. +

+
+
+ + +
+ + updateStyle("font-sans", value)} + /> +
+ + + +
+ + updateStyle("font-serif", value)} + /> +
+ + +
+ + updateStyle("font-mono", value)} + /> +
+
+ + + + updateStyle("letter-spacing", `${value}em`) + } + min={-0.5} + max={0.5} + step={0.025} + unit="em" + label="Letter Spacing" + /> + +
+ + + + updateStyle("radius", `${value}rem`)} + min={0} + max={5} + step={0.025} + unit="rem" + label="Radius" + /> + + + + updateStyle("spacing", `${value}rem`)} + min={0.15} + max={0.35} + step={0.01} + unit="rem" + label="Spacing" + /> + +
+ { + if (key === "shadow-color") { + updateStyle(key, value as string); + } else if (key === "shadow-opacity") { + updateStyle(key, value.toString()); + } else { + updateStyle(key as keyof ThemeStyleProps, `${value}px`); + } + }} + /> +
+
+ + +
+ ); }; diff --git a/components/editor/theme-preset-select.tsx b/components/editor/theme-preset-select.tsx index 7476f6bc..4f34d240 100644 --- a/components/editor/theme-preset-select.tsx +++ b/components/editor/theme-preset-select.tsx @@ -1,14 +1,18 @@ import React, { useCallback, useMemo, useState } from "react"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { ThemePreset } from "../../types/theme"; import { useEditorStore } from "../../store/editor-store"; import { getPresetThemeStyles } from "../../utils/theme-presets"; import { Button } from "../ui/button"; import { + ArrowLeft, + ArrowRight, Check, ChevronDown, - ChevronLeft, - ChevronRight, Moon, Search, Shuffle, @@ -17,7 +21,12 @@ import { import { useTheme } from "@/components/theme-provider"; import { Separator } from "../ui/separator"; import { ScrollArea } from "../ui/scroll-area"; -import { Command, CommandEmpty, CommandGroup, CommandItem } from "../ui/command"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, +} from "../ui/command"; import { Tooltip, TooltipContent, @@ -62,12 +71,16 @@ const ThemePresetSelect: React.FC = ({ const mode = themeState.currentMode; const [search, setSearch] = useState(""); - const presetNames = useMemo(() => ["default", ...Object.keys(presets)], [presets]); - const value = presetNames?.find((name) => name === currentPreset); - const currentIndex = useMemo( - () => presetNames.indexOf(value || "default"), - [presetNames, value] + const presetNames = useMemo( + () => ["default", ...Object.keys(presets)], + [presets] ); + const value = presetNames?.find((name) => name === currentPreset); + const currentIndex = + useMemo( + () => presetNames.indexOf(value || "default"), + [presetNames, value] + ) ?? 0; const randomize = useCallback(() => { const random = Math.floor(Math.random() * presetNames.length); @@ -107,14 +120,14 @@ const ThemePresetSelect: React.FC = ({ }; return ( -
+
- + - +
@@ -219,7 +240,9 @@ const ThemePresetSelect: React.FC = ({ color={getPresetThemeStyles(presetName)[mode].accent} /> = ({ {presets[presetName]?.label || presetName} - {presets[presetName] && isThemeNew(presets[presetName]) && ( - - New - - )} + {presets[presetName] && + isThemeNew(presets[presetName]) && ( + + New + + )}
{presetName === value && ( @@ -250,25 +274,37 @@ const ThemePresetSelect: React.FC = ({ - + - + + + + + Previous theme + + + + + + + + + Next theme +
); }; diff --git a/components/editor/theme-preview-panel.tsx b/components/editor/theme-preview-panel.tsx index 95ad252b..e9ff8a8b 100644 --- a/components/editor/theme-preview-panel.tsx +++ b/components/editor/theme-preview-panel.tsx @@ -8,7 +8,7 @@ import TabsTriggerPill from "./theme-preview/tabs-trigger-pill"; import ExamplesPreviewContainer from "./theme-preview/examples-preview-container"; import { lazy } from "react"; import { Button } from "@/components/ui/button"; -import { Maximize, Minimize, PanelRight, Moon, Sun } from "lucide-react"; +import { Maximize, Minimize, Moon, Sun } from "lucide-react"; import { useFullscreen } from "@/hooks/use-fullscreen"; import { cn } from "@/lib/utils"; import { useTheme } from "@/components/theme-provider"; @@ -17,6 +17,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { ActionBar } from "./action-bar"; const DemoCards = lazy(() => import("@/components/examples/demo-cards")); const DemoMail = lazy(() => import("@/components/examples/mail")); @@ -27,8 +28,6 @@ const DemoDashboard = lazy(() => import("@/components/examples/dashboard")); const ThemePreviewPanel = ({ styles, currentMode, - isCodePanelOpen, - onCodePanelToggle, }: ThemeEditorPreviewProps) => { const { isFullscreen, toggleFullscreen } = useFullscreen(); const { theme, toggleTheme } = useTheme(); @@ -43,91 +42,72 @@ const ThemePreviewPanel = ({ }; return ( -
-
-

Theme Preview

-
- {isFullscreen && ( - - - - - Toggle Theme - - )} - - - - - - {isFullscreen ? "Exit full screen" : "Full screen"} - - - {!isCodePanelOpen && !isFullscreen && ( - - - - - Hide Code Panel - - )} -
-
- -
+ <> + +
- - Cards -
- Mail - Tasks - Music - Dashboard -
- Color Palette -
+
+ + Cards +
+ Mail + Tasks + Music + Dashboard +
+ Color Palette +
- -
- +
+ {isFullscreen && ( + + + + + Toggle Theme + + )} + + + + + + {isFullscreen ? "Exit full screen" : "Full screen"} + + +
+
+ + +
+ @@ -146,7 +126,7 @@ const ThemePreviewPanel = ({ value="tasks" className="space-y-6 mt-0 h-full @container" > - + @@ -178,7 +158,7 @@ const ThemePreviewPanel = ({
-
+ ); }; diff --git a/hooks/use-contrast-checker.ts b/hooks/use-contrast-checker.ts new file mode 100644 index 00000000..0d553132 --- /dev/null +++ b/hooks/use-contrast-checker.ts @@ -0,0 +1,56 @@ +import { useState, useEffect, useCallback } from "react"; +import { getContrastRatio } from "../utils/contrast-checker"; +import { debounce } from "../utils/debounce"; + +type ColorPair = { + id: string; + foreground: string; + background: string; +}; + +type ContrastResult = { + id: string; + contrastRatio: number; +}; + +/** + * Hook that calculates the contrast ratio between foreground and background colors for a list of pairs. + * @param colorPairs - An array of color pairs, each with an id, foreground color, and background color. + * @returns An array of objects, each containing the id and calculated contrast ratio for a pair. + */ +export function useContrastChecker(colorPairs: ColorPair[]) { + const [contrastResults, setContrastResults] = useState([]); + + const debouncedCalculation = useCallback( + debounce((pairs: ColorPair[]) => { + if (!pairs.length) { + setContrastResults([]); + return; + } + + try { + const results = pairs.map((pair) => { + const ratio = parseFloat( + getContrastRatio(pair.foreground, pair.background) + ); + return { + id: pair.id, + contrastRatio: ratio, + }; + }); + + setContrastResults(results); + } catch (error) { + console.error("Error checking contrast:", error); + setContrastResults([]); + } + }, 750), + [] + ); + + useEffect(() => { + debouncedCalculation(colorPairs); + }, [colorPairs, debouncedCalculation]); + + return contrastResults; +} diff --git a/store/editor-store.ts b/store/editor-store.ts index e1942f20..71fe214f 100644 --- a/store/editor-store.ts +++ b/store/editor-store.ts @@ -1,9 +1,10 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { ThemeEditorState } from "@/types/editor"; +import { ThemeStyleProps } from "@/types/theme"; // @ts-expect-error: owned by ngard import { isEqual } from "@ngard/tiny-isequal"; -import { defaultThemeState } from "@/config/theme"; +import { defaultThemeState, COMMON_STYLES } from "@/config/theme"; import { getPresetThemeStyles } from "@/utils/theme-presets"; interface EditorStore { diff --git a/types/theme.ts b/types/theme.ts index a042fb5c..2b4e3dfb 100644 --- a/types/theme.ts +++ b/types/theme.ts @@ -58,8 +58,6 @@ export interface ThemeEditorState { export interface ThemeEditorPreviewProps { styles: ThemeStyles; currentMode: "light" | "dark"; - isCodePanelOpen: boolean; - onCodePanelToggle: (open: boolean) => void; } export interface ThemeEditorControlsProps { diff --git a/utils/contrast-checker.ts b/utils/contrast-checker.ts new file mode 100644 index 00000000..7dcbb5f1 --- /dev/null +++ b/utils/contrast-checker.ts @@ -0,0 +1,45 @@ +import * as culori from "culori"; + +/** + * Calculates the luminance of a color according to WCAG standard + * @param colorValue - The color in any supported format + * @returns The relative luminance of the color (0-1) + */ +function getLuminance(colorValue: string): number { + try { + const color = culori.parse(colorValue); + if (!color) { + console.warn(`Invalid color: ${colorValue}`); + return 0; + } + + // Culori directly provides the luminance according to WCAG standard + return culori.wcagLuminance(color); + } catch (error) { + console.error(`Error calculating luminance: ${colorValue}`, error); + return 0; + } +} + +/** + * Calculates the contrast ratio between two colors according to WCAG guidelines + * @param color1 - First color (in any format) + * @param color2 - Second color (in any format) + * @returns The contrast ratio as a string (e.g. "4.50") + */ +export function getContrastRatio(color1: string, color2: string): string { + try { + const lum1 = getLuminance(color1); + const lum2 = getLuminance(color2); + + // WCAG contrast ratio formula + const ratio = (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05); + return ratio.toFixed(2); + } catch (error) { + console.error( + `Error calculating contrast between ${color1} and ${color2}:`, + error + ); + return "1.00"; // Fallback value indicating low contrast + } +}