import { ChevronDownIcon, ChevronUpIcon, MousePointer2Icon, PaintbrushIcon, PlusIcon, XIcon,} from "lucide-react";import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent,} from "react";import { applyThemeColorPreview, THEME_COLOR_ROLES, THEME_FILE_VERSION, createVividThemeColors, getCustomThemes, getStandardThemeColors, getThemeColorsForMode, getThemeModes, installCustomTheme, isThemeColor, parseThemeFile, removeCustomTheme, themeIdFromName, updateThemeColorFamily, updateCustomTheme, type ThemeAppearance, type ThemeColors, type ThemeColorRole, type ThemeDefinition,} from "../../themePalette";import { cn } from "../../lib/utils";import { Button } from "../ui/button";import { Input } from "../ui/input";import { Switch } from "../ui/switch";import { Toggle, ToggleGroup } from "../ui/toggle-group";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import { getThemeRoleLabel, ThemeColorField } from "./ThemeColorPicker";import { clearThemeInspectorHover, clearThemeInspectorHighlights, highlightThemeRoleUsage, inspectThemeRoleAtElement, inspectThemeRoleFromUtilitiesAtElement, refreshThemeInspectorSpotlight, showThemeInspectorHover, type ThemeElementInspection,} from "./themeInspector"; const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray<ThemeColorRole> = ["canvas", "accent"]; type ThemeEditorColorFamily = Readonly<{ id: string; label: string; role: ThemeColorRole; roles: ReadonlyArray<ThemeColorRole>;}>; const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{ id: string; title: string; families: ReadonlyArray<ThemeEditorColorFamily>;}> = [ { id: "foundation", title: "Foundation", families: [ { id: "background", label: "Background", role: "canvas", roles: ["canvas", "chrome", "toolbar"], }, { id: "surface", label: "Surface", role: "surface", roles: ["surface"] }, { id: "raised-surface", label: "Raised surface", role: "surfaceRaised", roles: ["surfaceRaised"], }, { id: "overlay", label: "Overlay", role: "surfaceOverlay", roles: ["surfaceOverlay"], }, { id: "text", label: "Text", role: "text", roles: ["text", "toolbarForeground", "toolbarControlForeground"], }, { id: "muted-text", label: "Muted text", role: "mutedForeground", roles: [ "textMuted", "mutedForeground", "placeholder", "secondaryLabel", "iconMuted", "sidebarMutedForeground", ], }, { id: "border", label: "Border", role: "border", roles: ["border", "toolbarBorder", "sidebarBorder"], }, { id: "input", label: "Input", role: "input", roles: ["input"] }, ], }, { id: "brand-content", title: "Brand & content", families: [ { id: "subtle-surface", label: "Subtle surface", role: "secondary", roles: ["secondary", "secondaryForeground", "muted", "toolbarControl"], }, { id: "highlight-surface", label: "Highlight surface", role: "accentSurface", roles: ["accentSurface", "accentSurfaceForeground", "toolbarControlHover"], }, { id: "accent", label: "Accent", role: "accent", roles: [ "accent", "accentForeground", "focus", "update", "updateForeground", "updateSurface", "terminalCursor", ], }, { id: "action", label: "Action", role: "messageAction", roles: ["messageAction", "messageActionForeground", "messageActionHover"], }, { id: "message-surface", label: "Message surface", role: "messageSurface", roles: ["messageSurface", "messageForeground"], }, { id: "code-surface", label: "Code surface", role: "codeBackground", roles: ["codeBackground", "codeForeground"], }, ], }, { id: "context", title: "Context", families: [ { id: "sidebar-background", label: "Sidebar background", role: "sidebar", roles: ["sidebar", "sidebarForeground"], }, { id: "sidebar-controls", label: "Sidebar controls", role: "sidebarControlSurface", roles: ["sidebarControlSurface"], }, { id: "sidebar-selection", label: "Sidebar selection", role: "sidebarRowSelected", roles: ["sidebarRowHover", "sidebarRowActive", "sidebarRowSelected"], }, { id: "terminal-background", label: "Terminal background", role: "terminalBackground", roles: [ "terminalBackground", "terminalForeground", "terminalSelection", "terminalScrollbar", "terminalScrollbarHover", ], }, ], }, { id: "status", title: "Status", families: [ { id: "error", label: "Error", role: "error", roles: ["error", "errorForeground", "errorSurface"], }, { id: "warning", label: "Warning", role: "warning", roles: ["warning", "warningForeground", "warningSurface"], }, ], },]; const THEME_EDITOR_COLOR_FAMILIES = THEME_EDITOR_ROLE_GROUPS.flatMap((group) => group.families);const THEME_EDITOR_COLOR_FAMILY_BY_ROLE = new Map( THEME_EDITOR_COLOR_FAMILIES.flatMap((family) => family.roles.map((role) => [role, family] as const), ),); function getThemeEditorColorFamily(role: ThemeColorRole): ThemeEditorColorFamily | null { return THEME_EDITOR_COLOR_FAMILY_BY_ROLE.get(role) ?? null;} type ThemeEditorColors = ThemeColors;type ThemeEditorColorsByAppearance = Record<ThemeAppearance, ThemeEditorColors>; // A draft with no source theme starts as the standard T3 Code look — the// palette on screen when no theme is installed — so creating from the default// theme changes nothing until the user edits a color.function getThemeEditorDefaults(appearance: ThemeAppearance): ThemeEditorColors { return { ...getStandardThemeColors(appearance) };} function getThemeEditorColorsByAppearance(): ThemeEditorColorsByAppearance { return { light: getThemeEditorDefaults("light"), dark: getThemeEditorDefaults("dark"), };} function isThemeEditorColor(value: string): boolean { return isThemeColor(value.trim());} function getManagedEditorColors( appearance: ThemeAppearance, colors: ThemeEditorColors,): ThemeEditorColors { const defaults = getStandardThemeColors(appearance); // The editor keeps the user's exact picks and derives the rest through the // perceptual vivid engine, so a two-color theme carries its own identity. return createVividThemeColors( appearance, isThemeEditorColor(colors.canvas) ? colors.canvas : defaults.canvas, isThemeEditorColor(colors.accent) ? colors.accent : defaults.accent, );} export function ThemeEditorPanel({ open, onOpenChange, onSaved, editingTheme, initialAppearance, seedTheme, seedName, restoreTheme,}: { open: boolean; onOpenChange: (open: boolean) => void; onSaved: ( theme: ThemeDefinition, context: { created: boolean; /** Set when a create merged its palette into an existing theme. */ mergedAppearance?: ThemeAppearance; }, ) => boolean; editingTheme: ThemeDefinition | null; initialAppearance: ThemeAppearance; /** The theme a new theme starts from, so tuning what you already use is a * matter of editing rather than rebuilding. Null starts from the defaults. */ seedTheme?: ThemeDefinition | null; /** Prefilled name for an explicit duplicate; a plain create stays unnamed. */ seedName?: string | undefined; /** Reapplies the stored theme once the draft stops being previewed. */ restoreTheme: () => void;}) { const isEditing = editingTheme !== null; const [name, setName] = useState(""); const [activeAppearance, setActiveAppearance] = useState<ThemeAppearance>(initialAppearance); const [isAdvanced, setIsAdvanced] = useState(false); const [colorsByAppearance, setColorsByAppearance] = useState<ThemeEditorColorsByAppearance>(() => getThemeEditorColorsByAppearance(), ); const [simpleColorsDirtyByAppearance, setSimpleColorsDirtyByAppearance] = useState< Record<ThemeAppearance, boolean> >({ light: false, dark: false }); const [shouldRegenerateGuidedColors, setShouldRegenerateGuidedColors] = useState(false); const [error, setError] = useState<string | null>(null); const [isMinimized, setIsMinimized] = useState(false); const [roleQuery, setRoleQuery] = useState(""); const [isInspecting, setIsInspecting] = useState(false); const [selectedRole, setSelectedRole] = useState<ThemeColorRole | null>(null); const [usageCount, setUsageCount] = useState<number | null>(null); // Null parks the panel at its default corner; a value is a dragged spot, // kept clamped so the header can always be grabbed again. const [position, setPosition] = useState<{ x: number; y: number } | null>(null); // Null keeps the responsive default size; a value is a corner-grip resize. const [size, setSize] = useState<{ width: number; height: number } | null>(null); const panelRef = useRef<HTMLDivElement>(null); const dragOffsetRef = useRef<{ dx: number; dy: number } | null>(null); const resizeStartRef = useRef<{ pointerX: number; pointerY: number; // Where the panel's top-left sits: the grip only moves the opposite // corner, so the room to grow is measured from here. left: number; top: number; width: number; height: number; } | null>(null); useEffect(() => { if (!open) return; // A panel sized wider than the window can no longer be clamped back into // view by position alone -- its right edge (close, minimize, the grip) // stays off screen. So the size shrinks to fit first, then the position // is re-clamped against the new size. const clamp = () => { const margin = 8; let clampedWidth: number | undefined; let clampedHeight: number | undefined; setSize((current) => { if (!current) return current; clampedWidth = Math.max(280, Math.min(current.width, window.innerWidth - margin * 2)); clampedHeight = Math.max(220, Math.min(current.height, window.innerHeight - margin * 2)); return { width: clampedWidth, height: clampedHeight }; }); setPosition((current) => { if (!current) return current; const clamped = clampPosition(current.x, current.y, clampedWidth); // Dragging may park the panel with only its header showing, but a // window resize should pull the whole thing back into view when it // fits -- otherwise the grip ends up below the fold. Minimized, the // stored height is not applied (the panel hugs its header), so the // rendered height is what has to fit. const height = isMinimized ? (panelRef.current?.offsetHeight ?? 0) : (clampedHeight ?? panelRef.current?.offsetHeight ?? 0); const maxY = Math.max(margin, window.innerHeight - height - margin); return { x: clamped.x, y: Math.min(clamped.y, maxY) }; }); }; window.addEventListener("resize", clamp); return () => window.removeEventListener("resize", clamp); }, [isMinimized, open]); // The draft only reaches the live app once this open has been seeded; // previewing in the seeding commit would paint the previous session's // colors for a frame. const [isDraftSeeded, setIsDraftSeeded] = useState(false); const previousOpenRef = useRef(false); useEffect(() => { if (open && !previousOpenRef.current) { // Editing works on the theme itself; creating starts from the theme // that is currently in use, so tuning what you already run is an edit // away instead of a rebuild from the defaults. const sourceTheme = editingTheme ?? seedTheme ?? null; const nextColors = getThemeEditorColorsByAppearance(); const nextAppearance = sourceTheme ? getThemeColorsForMode(sourceTheme, initialAppearance) ? initialAppearance : sourceTheme.appearance : initialAppearance; if (sourceTheme) { nextColors[sourceTheme.appearance] = { ...sourceTheme.colors }; for (const appearance of ["light", "dark"] as const) { const variantColors = sourceTheme.variants?.[appearance]; if (variantColors) nextColors[appearance] = { ...variantColors }; } } setName(editingTheme?.label ?? seedName ?? ""); setActiveAppearance(nextAppearance); // Themes saved by the guided editor carry the managed flag; anything // else (imports, hand-edited files, older saves) opens in advanced mode // so guided regeneration cannot silently discard hand-tuned colors. A // seeded new theme follows the same rule: its palette is only safe to // regenerate when the guided editor produced it. setIsAdvanced(sourceTheme !== null && sourceTheme.managed !== true); setSimpleColorsDirtyByAppearance({ light: false, dark: false }); // An unmanaged palette needs conversion when the user opts into the // guided editor. Merely revealing Advanced for a managed/default draft // must stay read-only until a color changes. setShouldRegenerateGuidedColors(sourceTheme !== null && sourceTheme.managed !== true); setColorsByAppearance(nextColors); setSelectedRole(null); setUsageCount(null); setIsInspecting(false); setError(null); setIsDraftSeeded(true); } if (!open && isDraftSeeded) setIsDraftSeeded(false); previousOpenRef.current = open; }, [editingTheme, initialAppearance, isDraftSeeded, open, seedName, seedTheme]); // A name an installed theme already uses combines instead of failing: // creating adds the new palette to that theme, and renaming an existing // theme onto it folds the edited palette in and retires the old entry — // light "My Theme" plus a dark "My Theme" become one theme with both modes. // Labels are matched as well as derived ids: a rename keeps a theme's // original id, so its label is the only name a user can see and retype. const nameTargetId = themeIdFromName(name); const normalizedName = name.trim().toLowerCase(); const mergeTarget = normalizedName === "" ? null : (getCustomThemes().find( (theme) => theme.id !== editingTheme?.id && (theme.id === nameTargetId || theme.label.trim().toLowerCase() === normalizedName), ) ?? null); const takenAppearances = mergeTarget ? getThemeModes(mergeTarget) : []; const editableAppearances = editingTheme ? getThemeModes(editingTheme) : null; // The appearance a mode button would produce can be blocked two ways: the // merge target already has that palette, or the theme being edited never // had it (adding one is a create-with-same-name away). const appearanceLockReason = (appearance: ThemeAppearance): string | null => { if (editableAppearances && !editableAppearances.includes(appearance)) { return `“${editingTheme?.label}” has no ${appearance} palette. Create a theme with the same name to add one.`; } if (!isEditing && takenAppearances.includes(appearance)) { return `“${mergeTarget?.label}” already has a ${appearance} palette.`; } return null; }; // Typing a name whose theme already owns the selected appearance flips the // draft to the free side, so the merge affordance works without a manual // toggle. Both sides taken leaves the selection alone; save is blocked with // an explanation instead. const mergeTargetId = mergeTarget?.id ?? null; const takenAppearancesKey = takenAppearances.join(","); useEffect(() => { if (isEditing || mergeTargetId === null) return; const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[]; if (taken.length !== 1) return; setActiveAppearance((current) => { if (!taken.includes(current)) return current; return taken[0] === "light" ? "dark" : "light"; }); }, [isEditing, mergeTargetId, takenAppearancesKey]); // The whole app wears the draft while the editor is open, so a role change // is judged on the real interface rather than a miniature. The stored theme // comes back when the editor closes, including on cancel. useEffect(() => { if (!open || !isDraftSeeded) return; applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance); }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]); useEffect(() => { if (!open) return; return () => { restoreTheme(); }; }, [open, restoreTheme]); const updateColor = useCallback( (role: ThemeColorRole, value: string) => { setColorsByAppearance((current) => { const nextColors = { ...current[activeAppearance], [role]: value }; const shouldManageColors = !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value); return { ...current, [activeAppearance]: isAdvanced ? updateThemeColorFamily(activeAppearance, current[activeAppearance], role, value) : shouldManageColors ? getManagedEditorColors(activeAppearance, nextColors) : nextColors, }; }); if (!isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value)) {Lines 1–500 of 1304. Load more
import { ChevronDownIcon, ChevronUpIcon, MousePointer2Icon, PaintbrushIcon, PlusIcon, XIcon, } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, } from "react"; import { applyThemeColorPreview, THEME_COLOR_ROLES, THEME_FILE_VERSION, createVividThemeColors, getCustomThemes, getStandardThemeColors, getThemeColorsForMode, getThemeModes, installCustomTheme, isThemeColor, parseThemeFile, removeCustomTheme, themeIdFromName, updateThemeColorFamily, updateCustomTheme, type ThemeAppearance, type ThemeColors, type ThemeColorRole, type ThemeDefinition, } from "../../themePalette"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Switch } from "../ui/switch"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { getThemeRoleLabel, ThemeColorField } from "./ThemeColorPicker"; import { clearThemeInspectorHover, clearThemeInspectorHighlights, highlightThemeRoleUsage, inspectThemeRoleAtElement, inspectThemeRoleFromUtilitiesAtElement, refreshThemeInspectorSpotlight, showThemeInspectorHover, type ThemeElementInspection, } from "./themeInspector"; const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray<ThemeColorRole> = ["canvas", "accent"]; type ThemeEditorColorFamily = Readonly<{ id: string; label: string; role: ThemeColorRole; roles: ReadonlyArray<ThemeColorRole>; }>; const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{ id: string; title: string; families: ReadonlyArray<ThemeEditorColorFamily>; }> = [ { id: "foundation", title: "Foundation", families: [ { id: "background", label: "Background", role: "canvas", roles: ["canvas", "chrome", "toolbar"], }, { id: "surface", label: "Surface", role: "surface", roles: ["surface"] }, { id: "raised-surface", label: "Raised surface", role: "surfaceRaised", roles: ["surfaceRaised"], }, { id: "overlay", label: "Overlay", role: "surfaceOverlay", roles: ["surfaceOverlay"], }, { id: "text", label: "Text", role: "text", roles: ["text", "toolbarForeground", "toolbarControlForeground"], }, { id: "muted-text", label: "Muted text", role: "mutedForeground", roles: [ "textMuted", "mutedForeground", "placeholder", "secondaryLabel", "iconMuted", "sidebarMutedForeground", ], }, { id: "border", label: "Border", role: "border", roles: ["border", "toolbarBorder", "sidebarBorder"], }, { id: "input", label: "Input", role: "input", roles: ["input"] }, ], }, { id: "brand-content", title: "Brand & content", families: [ { id: "subtle-surface", label: "Subtle surface", role: "secondary", roles: ["secondary", "secondaryForeground", "muted", "toolbarControl"], }, { id: "highlight-surface", label: "Highlight surface", role: "accentSurface", roles: ["accentSurface", "accentSurfaceForeground", "toolbarControlHover"], }, { id: "accent", label: "Accent", role: "accent", roles: [ "accent", "accentForeground", "focus", "update", "updateForeground", "updateSurface", "terminalCursor", ], }, { id: "action", label: "Action", role: "messageAction", roles: ["messageAction", "messageActionForeground", "messageActionHover"], }, { id: "message-surface", label: "Message surface", role: "messageSurface", roles: ["messageSurface", "messageForeground"], }, { id: "code-surface", label: "Code surface", role: "codeBackground", roles: ["codeBackground", "codeForeground"], }, ], }, { id: "context", title: "Context", families: [ { id: "sidebar-background", label: "Sidebar background", role: "sidebar", roles: ["sidebar", "sidebarForeground"], }, { id: "sidebar-controls", label: "Sidebar controls", role: "sidebarControlSurface", roles: ["sidebarControlSurface"], }, { id: "sidebar-selection", label: "Sidebar selection", role: "sidebarRowSelected", roles: ["sidebarRowHover", "sidebarRowActive", "sidebarRowSelected"], }, { id: "terminal-background", label: "Terminal background", role: "terminalBackground", roles: [ "terminalBackground", "terminalForeground", "terminalSelection", "terminalScrollbar", "terminalScrollbarHover", ], }, ], }, { id: "status", title: "Status", families: [ { id: "error", label: "Error", role: "error", roles: ["error", "errorForeground", "errorSurface"], }, { id: "warning", label: "Warning", role: "warning", roles: ["warning", "warningForeground", "warningSurface"], }, ], }, ]; const THEME_EDITOR_COLOR_FAMILIES = THEME_EDITOR_ROLE_GROUPS.flatMap((group) => group.families); const THEME_EDITOR_COLOR_FAMILY_BY_ROLE = new Map( THEME_EDITOR_COLOR_FAMILIES.flatMap((family) => family.roles.map((role) => [role, family] as const), ), ); function getThemeEditorColorFamily(role: ThemeColorRole): ThemeEditorColorFamily | null { return THEME_EDITOR_COLOR_FAMILY_BY_ROLE.get(role) ?? null; } type ThemeEditorColors = ThemeColors; type ThemeEditorColorsByAppearance = Record<ThemeAppearance, ThemeEditorColors>; // A draft with no source theme starts as the standard T3 Code look — the // palette on screen when no theme is installed — so creating from the default // theme changes nothing until the user edits a color. function getThemeEditorDefaults(appearance: ThemeAppearance): ThemeEditorColors { return { ...getStandardThemeColors(appearance) }; } function getThemeEditorColorsByAppearance(): ThemeEditorColorsByAppearance { return { light: getThemeEditorDefaults("light"), dark: getThemeEditorDefaults("dark"), }; } function isThemeEditorColor(value: string): boolean { return isThemeColor(value.trim()); } function getManagedEditorColors( appearance: ThemeAppearance, colors: ThemeEditorColors, ): ThemeEditorColors { const defaults = getStandardThemeColors(appearance); // The editor keeps the user's exact picks and derives the rest through the // perceptual vivid engine, so a two-color theme carries its own identity. return createVividThemeColors( appearance, isThemeEditorColor(colors.canvas) ? colors.canvas : defaults.canvas, isThemeEditorColor(colors.accent) ? colors.accent : defaults.accent, ); } export function ThemeEditorPanel({ open, onOpenChange, onSaved, editingTheme, initialAppearance, seedTheme, seedName, restoreTheme, }: { open: boolean; onOpenChange: (open: boolean) => void; onSaved: ( theme: ThemeDefinition, context: { created: boolean; /** Set when a create merged its palette into an existing theme. */ mergedAppearance?: ThemeAppearance; }, ) => boolean; editingTheme: ThemeDefinition | null; initialAppearance: ThemeAppearance; /** The theme a new theme starts from, so tuning what you already use is a * matter of editing rather than rebuilding. Null starts from the defaults. */ seedTheme?: ThemeDefinition | null; /** Prefilled name for an explicit duplicate; a plain create stays unnamed. */ seedName?: string | undefined; /** Reapplies the stored theme once the draft stops being previewed. */ restoreTheme: () => void; }) { const isEditing = editingTheme !== null; const [name, setName] = useState(""); const [activeAppearance, setActiveAppearance] = useState<ThemeAppearance>(initialAppearance); const [isAdvanced, setIsAdvanced] = useState(false); const [colorsByAppearance, setColorsByAppearance] = useState<ThemeEditorColorsByAppearance>(() => getThemeEditorColorsByAppearance(), ); const [simpleColorsDirtyByAppearance, setSimpleColorsDirtyByAppearance] = useState< Record<ThemeAppearance, boolean> >({ light: false, dark: false }); const [shouldRegenerateGuidedColors, setShouldRegenerateGuidedColors] = useState(false); const [error, setError] = useState<string | null>(null); const [isMinimized, setIsMinimized] = useState(false); const [roleQuery, setRoleQuery] = useState(""); const [isInspecting, setIsInspecting] = useState(false); const [selectedRole, setSelectedRole] = useState<ThemeColorRole | null>(null); const [usageCount, setUsageCount] = useState<number | null>(null); // Null parks the panel at its default corner; a value is a dragged spot, // kept clamped so the header can always be grabbed again. const [position, setPosition] = useState<{ x: number; y: number } | null>(null); // Null keeps the responsive default size; a value is a corner-grip resize. const [size, setSize] = useState<{ width: number; height: number } | null>(null); const panelRef = useRef<HTMLDivElement>(null); const dragOffsetRef = useRef<{ dx: number; dy: number } | null>(null); const resizeStartRef = useRef<{ pointerX: number; pointerY: number; // Where the panel's top-left sits: the grip only moves the opposite // corner, so the room to grow is measured from here. left: number; top: number; width: number; height: number; } | null>(null); useEffect(() => { if (!open) return; // A panel sized wider than the window can no longer be clamped back into // view by position alone -- its right edge (close, minimize, the grip) // stays off screen. So the size shrinks to fit first, then the position // is re-clamped against the new size. const clamp = () => { const margin = 8; let clampedWidth: number | undefined; let clampedHeight: number | undefined; setSize((current) => { if (!current) return current; clampedWidth = Math.max(280, Math.min(current.width, window.innerWidth - margin * 2)); clampedHeight = Math.max(220, Math.min(current.height, window.innerHeight - margin * 2)); return { width: clampedWidth, height: clampedHeight }; }); setPosition((current) => { if (!current) return current; const clamped = clampPosition(current.x, current.y, clampedWidth); // Dragging may park the panel with only its header showing, but a // window resize should pull the whole thing back into view when it // fits -- otherwise the grip ends up below the fold. Minimized, the // stored height is not applied (the panel hugs its header), so the // rendered height is what has to fit. const height = isMinimized ? (panelRef.current?.offsetHeight ?? 0) : (clampedHeight ?? panelRef.current?.offsetHeight ?? 0); const maxY = Math.max(margin, window.innerHeight - height - margin); return { x: clamped.x, y: Math.min(clamped.y, maxY) }; }); }; window.addEventListener("resize", clamp); return () => window.removeEventListener("resize", clamp); }, [isMinimized, open]); // The draft only reaches the live app once this open has been seeded; // previewing in the seeding commit would paint the previous session's // colors for a frame. const [isDraftSeeded, setIsDraftSeeded] = useState(false); const previousOpenRef = useRef(false); useEffect(() => { if (open && !previousOpenRef.current) { // Editing works on the theme itself; creating starts from the theme // that is currently in use, so tuning what you already run is an edit // away instead of a rebuild from the defaults. const sourceTheme = editingTheme ?? seedTheme ?? null; const nextColors = getThemeEditorColorsByAppearance(); const nextAppearance = sourceTheme ? getThemeColorsForMode(sourceTheme, initialAppearance) ? initialAppearance : sourceTheme.appearance : initialAppearance; if (sourceTheme) { nextColors[sourceTheme.appearance] = { ...sourceTheme.colors }; for (const appearance of ["light", "dark"] as const) { const variantColors = sourceTheme.variants?.[appearance]; if (variantColors) nextColors[appearance] = { ...variantColors }; } } setName(editingTheme?.label ?? seedName ?? ""); setActiveAppearance(nextAppearance); // Themes saved by the guided editor carry the managed flag; anything // else (imports, hand-edited files, older saves) opens in advanced mode // so guided regeneration cannot silently discard hand-tuned colors. A // seeded new theme follows the same rule: its palette is only safe to // regenerate when the guided editor produced it. setIsAdvanced(sourceTheme !== null && sourceTheme.managed !== true); setSimpleColorsDirtyByAppearance({ light: false, dark: false }); // An unmanaged palette needs conversion when the user opts into the // guided editor. Merely revealing Advanced for a managed/default draft // must stay read-only until a color changes. setShouldRegenerateGuidedColors(sourceTheme !== null && sourceTheme.managed !== true); setColorsByAppearance(nextColors); setSelectedRole(null); setUsageCount(null); setIsInspecting(false); setError(null); setIsDraftSeeded(true); } if (!open && isDraftSeeded) setIsDraftSeeded(false); previousOpenRef.current = open; }, [editingTheme, initialAppearance, isDraftSeeded, open, seedName, seedTheme]); // A name an installed theme already uses combines instead of failing: // creating adds the new palette to that theme, and renaming an existing // theme onto it folds the edited palette in and retires the old entry — // light "My Theme" plus a dark "My Theme" become one theme with both modes. // Labels are matched as well as derived ids: a rename keeps a theme's // original id, so its label is the only name a user can see and retype. const nameTargetId = themeIdFromName(name); const normalizedName = name.trim().toLowerCase(); const mergeTarget = normalizedName === "" ? null : (getCustomThemes().find( (theme) => theme.id !== editingTheme?.id && (theme.id === nameTargetId || theme.label.trim().toLowerCase() === normalizedName), ) ?? null); const takenAppearances = mergeTarget ? getThemeModes(mergeTarget) : []; const editableAppearances = editingTheme ? getThemeModes(editingTheme) : null; // The appearance a mode button would produce can be blocked two ways: the // merge target already has that palette, or the theme being edited never // had it (adding one is a create-with-same-name away). const appearanceLockReason = (appearance: ThemeAppearance): string | null => { if (editableAppearances && !editableAppearances.includes(appearance)) { return `“${editingTheme?.label}” has no ${appearance} palette. Create a theme with the same name to add one.`; } if (!isEditing && takenAppearances.includes(appearance)) { return `“${mergeTarget?.label}” already has a ${appearance} palette.`; } return null; }; // Typing a name whose theme already owns the selected appearance flips the // draft to the free side, so the merge affordance works without a manual // toggle. Both sides taken leaves the selection alone; save is blocked with // an explanation instead. const mergeTargetId = mergeTarget?.id ?? null; const takenAppearancesKey = takenAppearances.join(","); useEffect(() => { if (isEditing || mergeTargetId === null) return; const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[]; if (taken.length !== 1) return; setActiveAppearance((current) => { if (!taken.includes(current)) return current; return taken[0] === "light" ? "dark" : "light"; }); }, [isEditing, mergeTargetId, takenAppearancesKey]); // The whole app wears the draft while the editor is open, so a role change // is judged on the real interface rather than a miniature. The stored theme // comes back when the editor closes, including on cancel. useEffect(() => { if (!open || !isDraftSeeded) return; applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance); }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]); useEffect(() => { if (!open) return; return () => { restoreTheme(); }; }, [open, restoreTheme]); const updateColor = useCallback( (role: ThemeColorRole, value: string) => { setColorsByAppearance((current) => { const nextColors = { ...current[activeAppearance], [role]: value }; const shouldManageColors = !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value); return { ...current, [activeAppearance]: isAdvanced ? updateThemeColorFamily(activeAppearance, current[activeAppearance], role, value) : shouldManageColors ? getManagedEditorColors(activeAppearance, nextColors) : nextColors, }; }); if (!isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value)) {