import { ChevronDownIcon, ChevronUpIcon, MousePointer2Icon, 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, updateCustomTheme, type ThemeAppearance, 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 { 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_PRIMARY_ROLES: ReadonlyArray<ThemeColorRole> = [ "canvas", "chrome", "sidebar", "surface", "text", "textMuted", "placeholder", "secondaryLabel", "iconMuted", "accent", "messageSurface", "messageAction",]; const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray<ThemeColorRole> = ["canvas", "accent"]; const THEME_EDITOR_STATUS_ROLES: ReadonlyArray<ThemeColorRole> = [ "error", "errorForeground", "errorSurface", "warning", "warningForeground", "warningSurface", "update", "updateForeground", "updateSurface",]; const THEME_EDITOR_ADVANCED_ROLES = THEME_COLOR_ROLES.filter( (role) => !THEME_EDITOR_PRIMARY_ROLES.includes(role) && !THEME_EDITOR_STATUS_ROLES.includes(role),); const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{ id: string; title: string; roles: ReadonlyArray<ThemeColorRole>;}> = [ { id: "main", title: "Main colors", roles: THEME_EDITOR_PRIMARY_ROLES, }, { id: "status", title: "Status colors", roles: THEME_EDITOR_STATUS_ROLES, }, { id: "additional", title: "Other colors", roles: THEME_EDITOR_ADVANCED_ROLES, },]; type ThemeEditorColors = Record<ThemeColorRole, string>;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 [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 }); 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]: shouldManageColors ? getManagedEditorColors(activeAppearance, nextColors) : nextColors, }; }); if (!isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value)) { setSimpleColorsDirtyByAppearance((current) => ({ ...current, [activeAppearance]: true, })); } }, [activeAppearance, isAdvanced], ); const selectThemeRole = useCallback((role: ThemeColorRole, reveal = false) => { setSelectedRole(role); if (!THEME_EDITOR_SIMPLE_ROLES.includes(role)) { setIsAdvanced(true); setRoleQuery(""); } if (!reveal) return; requestAnimationFrame(() => { panelRef.current ?.querySelector(`[data-theme-color-role="${role}"]`) ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }); }, []); const toggleThemeRole = useCallback((role: ThemeColorRole) => { setSelectedRole((current) => (current === role ? null : role)); }, []); const clearInspectorSelection = useCallback(() => { setSelectedRole(null); setUsageCount(null); setIsInspecting(false); }, []); const selectedHighlightRoles = selectedRole ? !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole) ? THEME_COLOR_ROLES.filter( (role) => colorsByAppearance[activeAppearance][role].trim().toLowerCase() === colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(), ) : [selectedRole] : []; const selectedHighlightRolesKey = selectedHighlightRoles.join(","); useEffect(() => { clearThemeInspectorHighlights(); if (!open || selectedRole === null) { setUsageCount(null); return; } // Picking a new element needs the unobscured app, so suspend the existing // spotlight while the picker is armed. if (isInspecting) return; const highlightedRoles = selectedHighlightRolesKey.split(",") as Array<ThemeColorRole>; const refreshHighlights = () => setUsageCount(highlightThemeRoleUsage(highlightedRoles)); refreshHighlights(); // A refresh snapshots computed styles for the whole tree twice, so it is // throttled rather than run per frame: a streaming reply or a virtualized // list mutates the DOM continuously and would otherwise stall the main // thread for as long as the inspector is open. const MIN_REFRESH_INTERVAL_MS = 500; let refreshFrame: number | null = null; let refreshTimer: ReturnType<typeof setTimeout> | null = null; let lastRefreshAt = performance.now(); const scheduleRefresh = () => { if (refreshFrame !== null || refreshTimer !== null) return; const wait = Math.max(0, MIN_REFRESH_INTERVAL_MS - (performance.now() - lastRefreshAt)); const run = () => { refreshFrame = null; refreshTimer = null; lastRefreshAt = performance.now(); refreshHighlights(); }; if (wait === 0) refreshFrame = requestAnimationFrame(run); else refreshTimer = setTimeout(run, wait); }; const observer = new MutationObserver((mutations) => { if ( mutations.every( (mutation) => mutation.target instanceof Element && (mutation.target.closest("#theme-inspector-spotlight") || mutation.target.closest("[data-theme-editor-panel]")), ) ) { return; } scheduleRefresh(); }); observer.observe(document.body, { childList: true, subtree: true }); let spotlightFrame: number | null = null; const scheduleSpotlightRefresh = () => { spotlightFrame ??= requestAnimationFrame(() => { spotlightFrame = null; refreshThemeInspectorSpotlight(); }); }; window.addEventListener("resize", scheduleSpotlightRefresh); window.addEventListener("scroll", scheduleSpotlightRefresh, true); return () => { observer.disconnect(); if (refreshFrame !== null) cancelAnimationFrame(refreshFrame); if (refreshTimer !== null) clearTimeout(refreshTimer); if (spotlightFrame !== null) cancelAnimationFrame(spotlightFrame); window.removeEventListener("resize", scheduleSpotlightRefresh); window.removeEventListener("scroll", scheduleSpotlightRefresh, true); clearThemeInspectorHighlights(); }; }, [isInspecting, open, selectedHighlightRolesKey, selectedRole]); useEffect(() => { if (!open || !isInspecting) { clearThemeInspectorHover(); return; } let shouldDisarmAfterClick = false; let hoverTarget: Element | null = null; let hoverInspection: ThemeElementInspection | null = null; let hoverTimer: number | null = null; let hoverFrame: number | null = null; const clearHoverTimer = () => { if (hoverTimer === null) return; window.clearTimeout(hoverTimer); hoverTimer = null; }; const clearHover = () => { clearHoverTimer(); hoverTarget = null; hoverInspection = null; clearThemeInspectorHover(); }; const showInspection = (inspection: ThemeElementInspection) => { hoverInspection = inspection; showThemeInspectorHover(inspection, getThemeRoleLabel(inspection.role)); }; const handlePointerOver = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) { clearHover(); return; }Lines 1–500 of 1133. Load more
import { ChevronDownIcon, ChevronUpIcon, MousePointer2Icon, 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, updateCustomTheme, type ThemeAppearance, 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 { 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_PRIMARY_ROLES: ReadonlyArray<ThemeColorRole> = [ "canvas", "chrome", "sidebar", "surface", "text", "textMuted", "placeholder", "secondaryLabel", "iconMuted", "accent", "messageSurface", "messageAction", ]; const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray<ThemeColorRole> = ["canvas", "accent"]; const THEME_EDITOR_STATUS_ROLES: ReadonlyArray<ThemeColorRole> = [ "error", "errorForeground", "errorSurface", "warning", "warningForeground", "warningSurface", "update", "updateForeground", "updateSurface", ]; const THEME_EDITOR_ADVANCED_ROLES = THEME_COLOR_ROLES.filter( (role) => !THEME_EDITOR_PRIMARY_ROLES.includes(role) && !THEME_EDITOR_STATUS_ROLES.includes(role), ); const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{ id: string; title: string; roles: ReadonlyArray<ThemeColorRole>; }> = [ { id: "main", title: "Main colors", roles: THEME_EDITOR_PRIMARY_ROLES, }, { id: "status", title: "Status colors", roles: THEME_EDITOR_STATUS_ROLES, }, { id: "additional", title: "Other colors", roles: THEME_EDITOR_ADVANCED_ROLES, }, ]; type ThemeEditorColors = Record<ThemeColorRole, string>; 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 [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 }); 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]: shouldManageColors ? getManagedEditorColors(activeAppearance, nextColors) : nextColors, }; }); if (!isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value)) { setSimpleColorsDirtyByAppearance((current) => ({ ...current, [activeAppearance]: true, })); } }, [activeAppearance, isAdvanced], ); const selectThemeRole = useCallback((role: ThemeColorRole, reveal = false) => { setSelectedRole(role); if (!THEME_EDITOR_SIMPLE_ROLES.includes(role)) { setIsAdvanced(true); setRoleQuery(""); } if (!reveal) return; requestAnimationFrame(() => { panelRef.current ?.querySelector(`[data-theme-color-role="${role}"]`) ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }); }, []); const toggleThemeRole = useCallback((role: ThemeColorRole) => { setSelectedRole((current) => (current === role ? null : role)); }, []); const clearInspectorSelection = useCallback(() => { setSelectedRole(null); setUsageCount(null); setIsInspecting(false); }, []); const selectedHighlightRoles = selectedRole ? !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole) ? THEME_COLOR_ROLES.filter( (role) => colorsByAppearance[activeAppearance][role].trim().toLowerCase() === colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(), ) : [selectedRole] : []; const selectedHighlightRolesKey = selectedHighlightRoles.join(","); useEffect(() => { clearThemeInspectorHighlights(); if (!open || selectedRole === null) { setUsageCount(null); return; } // Picking a new element needs the unobscured app, so suspend the existing // spotlight while the picker is armed. if (isInspecting) return; const highlightedRoles = selectedHighlightRolesKey.split(",") as Array<ThemeColorRole>; const refreshHighlights = () => setUsageCount(highlightThemeRoleUsage(highlightedRoles)); refreshHighlights(); // A refresh snapshots computed styles for the whole tree twice, so it is // throttled rather than run per frame: a streaming reply or a virtualized // list mutates the DOM continuously and would otherwise stall the main // thread for as long as the inspector is open. const MIN_REFRESH_INTERVAL_MS = 500; let refreshFrame: number | null = null; let refreshTimer: ReturnType<typeof setTimeout> | null = null; let lastRefreshAt = performance.now(); const scheduleRefresh = () => { if (refreshFrame !== null || refreshTimer !== null) return; const wait = Math.max(0, MIN_REFRESH_INTERVAL_MS - (performance.now() - lastRefreshAt)); const run = () => { refreshFrame = null; refreshTimer = null; lastRefreshAt = performance.now(); refreshHighlights(); }; if (wait === 0) refreshFrame = requestAnimationFrame(run); else refreshTimer = setTimeout(run, wait); }; const observer = new MutationObserver((mutations) => { if ( mutations.every( (mutation) => mutation.target instanceof Element && (mutation.target.closest("#theme-inspector-spotlight") || mutation.target.closest("[data-theme-editor-panel]")), ) ) { return; } scheduleRefresh(); }); observer.observe(document.body, { childList: true, subtree: true }); let spotlightFrame: number | null = null; const scheduleSpotlightRefresh = () => { spotlightFrame ??= requestAnimationFrame(() => { spotlightFrame = null; refreshThemeInspectorSpotlight(); }); }; window.addEventListener("resize", scheduleSpotlightRefresh); window.addEventListener("scroll", scheduleSpotlightRefresh, true); return () => { observer.disconnect(); if (refreshFrame !== null) cancelAnimationFrame(refreshFrame); if (refreshTimer !== null) clearTimeout(refreshTimer); if (spotlightFrame !== null) cancelAnimationFrame(spotlightFrame); window.removeEventListener("resize", scheduleSpotlightRefresh); window.removeEventListener("scroll", scheduleSpotlightRefresh, true); clearThemeInspectorHighlights(); }; }, [isInspecting, open, selectedHighlightRolesKey, selectedRole]); useEffect(() => { if (!open || !isInspecting) { clearThemeInspectorHover(); return; } let shouldDisarmAfterClick = false; let hoverTarget: Element | null = null; let hoverInspection: ThemeElementInspection | null = null; let hoverTimer: number | null = null; let hoverFrame: number | null = null; const clearHoverTimer = () => { if (hoverTimer === null) return; window.clearTimeout(hoverTimer); hoverTimer = null; }; const clearHover = () => { clearHoverTimer(); hoverTarget = null; hoverInspection = null; clearThemeInspectorHover(); }; const showInspection = (inspection: ThemeElementInspection) => { hoverInspection = inspection; showThemeInspectorHover(inspection, getThemeRoleLabel(inspection.role)); }; const handlePointerOver = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) { clearHover(); return; }