1304 lines · 46.8 KB · TypeScript
RawDownload
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.