| 1 | import { MousePointer2Icon, PaintbrushIcon, PlusIcon, XIcon } from "lucide-react"; |
| 2 | import { ChevronDown, ChevronUp } from "lucide"; |
| 3 | import { |
| 4 | useCallback, |
| 5 | useEffect, |
| 6 | useRef, |
| 7 | useState, |
| 8 | type PointerEvent as ReactPointerEvent, |
| 9 | } from "react"; |
| 10 | import { |
| 11 | applyThemeColorPreview, |
| 12 | THEME_COLOR_ROLES, |
| 13 | THEME_FILE_VERSION, |
| 14 | createVividThemeColors, |
| 15 | getCustomThemes, |
| 16 | getStandardThemeColors, |
| 17 | getThemeColorsForMode, |
| 18 | getThemeModes, |
| 19 | installCustomTheme, |
| 20 | isThemeColor, |
| 21 | parseThemeFile, |
| 22 | removeCustomTheme, |
| 23 | themeIdFromName, |
| 24 | updateThemeColorFamily, |
| 25 | updateCustomTheme, |
| 26 | type ThemeAppearance, |
| 27 | type ThemeColors, |
| 28 | type ThemeColorRole, |
| 29 | type ThemeDefinition, |
| 30 | } from "../../themePalette"; |
| 31 | import { cn } from "../../lib/utils"; |
| 32 | import { Button } from "../ui/button"; |
| 33 | import { MorphIcon } from "~/components/MorphIcon"; |
| 34 | import { Input } from "../ui/input"; |
| 35 | import { Switch } from "../ui/switch"; |
| 36 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 37 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 38 | import { getThemeRoleLabel, ThemeColorField } from "./ThemeColorPicker"; |
| 39 | import { |
| 40 | clearThemeInspectorHover, |
| 41 | clearThemeInspectorHighlights, |
| 42 | highlightThemeRoleUsage, |
| 43 | inspectThemeRoleAtElement, |
| 44 | inspectThemeRoleFromUtilitiesAtElement, |
| 45 | refreshThemeInspectorSpotlight, |
| 46 | showThemeInspectorHover, |
| 47 | type ThemeElementInspection, |
| 48 | } from "./themeInspector"; |
| 49 | |
| 50 | const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray<ThemeColorRole> = ["canvas", "accent"]; |
| 51 | |
| 52 | type ThemeEditorColorFamily = Readonly<{ |
| 53 | id: string; |
| 54 | label: string; |
| 55 | role: ThemeColorRole; |
| 56 | roles: ReadonlyArray<ThemeColorRole>; |
| 57 | }>; |
| 58 | |
| 59 | const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{ |
| 60 | id: string; |
| 61 | title: string; |
| 62 | families: ReadonlyArray<ThemeEditorColorFamily>; |
| 63 | }> = [ |
| 64 | { |
| 65 | id: "foundation", |
| 66 | title: "Foundation", |
| 67 | families: [ |
| 68 | { |
| 69 | id: "background", |
| 70 | label: "Background", |
| 71 | role: "canvas", |
| 72 | roles: ["canvas", "chrome", "toolbar"], |
| 73 | }, |
| 74 | { id: "surface", label: "Surface", role: "surface", roles: ["surface"] }, |
| 75 | { |
| 76 | id: "raised-surface", |
| 77 | label: "Raised surface", |
| 78 | role: "surfaceRaised", |
| 79 | roles: ["surfaceRaised"], |
| 80 | }, |
| 81 | { |
| 82 | id: "overlay", |
| 83 | label: "Overlay", |
| 84 | role: "surfaceOverlay", |
| 85 | roles: ["surfaceOverlay"], |
| 86 | }, |
| 87 | { |
| 88 | id: "text", |
| 89 | label: "Text", |
| 90 | role: "text", |
| 91 | roles: ["text", "toolbarForeground", "toolbarControlForeground"], |
| 92 | }, |
| 93 | { |
| 94 | id: "muted-text", |
| 95 | label: "Muted text", |
| 96 | role: "mutedForeground", |
| 97 | roles: [ |
| 98 | "textMuted", |
| 99 | "mutedForeground", |
| 100 | "placeholder", |
| 101 | "secondaryLabel", |
| 102 | "iconMuted", |
| 103 | "sidebarMutedForeground", |
| 104 | ], |
| 105 | }, |
| 106 | { |
| 107 | id: "border", |
| 108 | label: "Border", |
| 109 | role: "border", |
| 110 | roles: ["border", "toolbarBorder", "sidebarBorder"], |
| 111 | }, |
| 112 | { id: "input", label: "Input", role: "input", roles: ["input"] }, |
| 113 | ], |
| 114 | }, |
| 115 | { |
| 116 | id: "brand-content", |
| 117 | title: "Brand & content", |
| 118 | families: [ |
| 119 | { |
| 120 | id: "subtle-surface", |
| 121 | label: "Subtle surface", |
| 122 | role: "secondary", |
| 123 | roles: ["secondary", "secondaryForeground", "muted", "toolbarControl"], |
| 124 | }, |
| 125 | { |
| 126 | id: "highlight-surface", |
| 127 | label: "Highlight surface", |
| 128 | role: "accentSurface", |
| 129 | roles: ["accentSurface", "accentSurfaceForeground", "toolbarControlHover"], |
| 130 | }, |
| 131 | { |
| 132 | id: "accent", |
| 133 | label: "Accent", |
| 134 | role: "accent", |
| 135 | roles: [ |
| 136 | "accent", |
| 137 | "accentForeground", |
| 138 | "focus", |
| 139 | "update", |
| 140 | "updateForeground", |
| 141 | "updateSurface", |
| 142 | "terminalCursor", |
| 143 | ], |
| 144 | }, |
| 145 | { |
| 146 | id: "action", |
| 147 | label: "Action", |
| 148 | role: "messageAction", |
| 149 | roles: ["messageAction", "messageActionForeground", "messageActionHover"], |
| 150 | }, |
| 151 | { |
| 152 | id: "message-surface", |
| 153 | label: "Message surface", |
| 154 | role: "messageSurface", |
| 155 | roles: ["messageSurface", "messageForeground"], |
| 156 | }, |
| 157 | { |
| 158 | id: "code-surface", |
| 159 | label: "Code surface", |
| 160 | role: "codeBackground", |
| 161 | roles: ["codeBackground", "codeForeground"], |
| 162 | }, |
| 163 | ], |
| 164 | }, |
| 165 | { |
| 166 | id: "search", |
| 167 | title: "Search", |
| 168 | families: [ |
| 169 | { |
| 170 | id: "search-matches", |
| 171 | label: "Search matches", |
| 172 | role: "searchMatchBackground", |
| 173 | roles: ["searchMatchBackground", "searchMatchForeground"], |
| 174 | }, |
| 175 | { |
| 176 | id: "current-match", |
| 177 | label: "Current match", |
| 178 | role: "searchMatchActiveBackground", |
| 179 | roles: ["searchMatchActiveBackground", "searchMatchActiveForeground"], |
| 180 | }, |
| 181 | ], |
| 182 | }, |
| 183 | { |
| 184 | id: "context", |
| 185 | title: "Context", |
| 186 | families: [ |
| 187 | { |
| 188 | id: "sidebar-background", |
| 189 | label: "Sidebar background", |
| 190 | role: "sidebar", |
| 191 | roles: ["sidebar", "sidebarForeground"], |
| 192 | }, |
| 193 | { |
| 194 | id: "sidebar-controls", |
| 195 | label: "Sidebar controls", |
| 196 | role: "sidebarControlSurface", |
| 197 | roles: ["sidebarControlSurface"], |
| 198 | }, |
| 199 | { |
| 200 | id: "sidebar-selection", |
| 201 | label: "Sidebar selection", |
| 202 | role: "sidebarRowSelected", |
| 203 | roles: ["sidebarRowHover", "sidebarRowActive", "sidebarRowSelected"], |
| 204 | }, |
| 205 | { |
| 206 | id: "terminal-background", |
| 207 | label: "Terminal background", |
| 208 | role: "terminalBackground", |
| 209 | roles: [ |
| 210 | "terminalBackground", |
| 211 | "terminalForeground", |
| 212 | "terminalSelection", |
| 213 | "terminalScrollbar", |
| 214 | "terminalScrollbarHover", |
| 215 | ], |
| 216 | }, |
| 217 | ], |
| 218 | }, |
| 219 | { |
| 220 | id: "status", |
| 221 | title: "Status", |
| 222 | families: [ |
| 223 | { |
| 224 | id: "error", |
| 225 | label: "Error", |
| 226 | role: "error", |
| 227 | roles: ["error", "errorForeground", "errorSurface"], |
| 228 | }, |
| 229 | { |
| 230 | id: "warning", |
| 231 | label: "Warning", |
| 232 | role: "warning", |
| 233 | roles: ["warning", "warningForeground", "warningSurface"], |
| 234 | }, |
| 235 | ], |
| 236 | }, |
| 237 | ]; |
| 238 | |
| 239 | const THEME_EDITOR_COLOR_FAMILIES = THEME_EDITOR_ROLE_GROUPS.flatMap((group) => group.families); |
| 240 | const THEME_EDITOR_COLOR_FAMILY_BY_ROLE = new Map( |
| 241 | THEME_EDITOR_COLOR_FAMILIES.flatMap((family) => |
| 242 | family.roles.map((role) => [role, family] as const), |
| 243 | ), |
| 244 | ); |
| 245 | |
| 246 | function getThemeEditorColorFamily(role: ThemeColorRole): ThemeEditorColorFamily | null { |
| 247 | return THEME_EDITOR_COLOR_FAMILY_BY_ROLE.get(role) ?? null; |
| 248 | } |
| 249 | |
| 250 | type ThemeEditorColors = ThemeColors; |
| 251 | type ThemeEditorColorsByAppearance = Record<ThemeAppearance, ThemeEditorColors>; |
| 252 | |
| 253 | // A draft with no source theme starts as the standard T3 Code look — the |
| 254 | // palette on screen when no theme is installed — so creating from the default |
| 255 | // theme changes nothing until the user edits a color. |
| 256 | function getThemeEditorDefaults(appearance: ThemeAppearance): ThemeEditorColors { |
| 257 | return { ...getStandardThemeColors(appearance) }; |
| 258 | } |
| 259 | |
| 260 | function getThemeEditorColorsByAppearance(): ThemeEditorColorsByAppearance { |
| 261 | return { |
| 262 | light: getThemeEditorDefaults("light"), |
| 263 | dark: getThemeEditorDefaults("dark"), |
| 264 | }; |
| 265 | } |
| 266 | |
| 267 | function isThemeEditorColor(value: string): boolean { |
| 268 | return isThemeColor(value.trim()); |
| 269 | } |
| 270 | |
| 271 | function getManagedEditorColors( |
| 272 | appearance: ThemeAppearance, |
| 273 | colors: ThemeEditorColors, |
| 274 | ): ThemeEditorColors { |
| 275 | const defaults = getStandardThemeColors(appearance); |
| 276 | // The editor keeps the user's exact picks and derives the rest through the |
| 277 | // perceptual vivid engine, so a two-color theme carries its own identity. |
| 278 | return createVividThemeColors( |
| 279 | appearance, |
| 280 | isThemeEditorColor(colors.canvas) ? colors.canvas : defaults.canvas, |
| 281 | isThemeEditorColor(colors.accent) ? colors.accent : defaults.accent, |
| 282 | ); |
| 283 | } |
| 284 | |
| 285 | export function ThemeEditorPanel({ |
| 286 | open, |
| 287 | onOpenChange, |
| 288 | onSaved, |
| 289 | editingTheme, |
| 290 | initialAppearance, |
| 291 | seedTheme, |
| 292 | seedName, |
| 293 | restoreTheme, |
| 294 | }: { |
| 295 | open: boolean; |
| 296 | onOpenChange: (open: boolean) => void; |
| 297 | onSaved: ( |
| 298 | theme: ThemeDefinition, |
| 299 | context: { |
| 300 | created: boolean; |
| 301 | /** Set when a create merged its palette into an existing theme. */ |
| 302 | mergedAppearance?: ThemeAppearance; |
| 303 | }, |
| 304 | ) => boolean; |
| 305 | editingTheme: ThemeDefinition | null; |
| 306 | initialAppearance: ThemeAppearance; |
| 307 | /** The theme a new theme starts from, so tuning what you already use is a |
| 308 | * matter of editing rather than rebuilding. Null starts from the defaults. */ |
| 309 | seedTheme?: ThemeDefinition | null; |
| 310 | /** Prefilled name for an explicit duplicate; a plain create stays unnamed. */ |
| 311 | seedName?: string | undefined; |
| 312 | /** Reapplies the stored theme once the draft stops being previewed. */ |
| 313 | restoreTheme: () => void; |
| 314 | }) { |
| 315 | const isEditing = editingTheme !== null; |
| 316 | const [name, setName] = useState(""); |
| 317 | const [activeAppearance, setActiveAppearance] = useState<ThemeAppearance>(initialAppearance); |
| 318 | const [isAdvanced, setIsAdvanced] = useState(false); |
| 319 | const [colorsByAppearance, setColorsByAppearance] = useState<ThemeEditorColorsByAppearance>(() => |
| 320 | getThemeEditorColorsByAppearance(), |
| 321 | ); |
| 322 | const [simpleColorsDirtyByAppearance, setSimpleColorsDirtyByAppearance] = useState< |
| 323 | Record<ThemeAppearance, boolean> |
| 324 | >({ light: false, dark: false }); |
| 325 | const [shouldRegenerateGuidedColors, setShouldRegenerateGuidedColors] = useState(false); |
| 326 | const [error, setError] = useState<string | null>(null); |
| 327 | const [isMinimized, setIsMinimized] = useState(false); |
| 328 | const [roleQuery, setRoleQuery] = useState(""); |
| 329 | const [isInspecting, setIsInspecting] = useState(false); |
| 330 | const [selectedRole, setSelectedRole] = useState<ThemeColorRole | null>(null); |
| 331 | const [usageCount, setUsageCount] = useState<number | null>(null); |
| 332 | // Null parks the panel at its default corner; a value is a dragged spot, |
| 333 | // kept clamped so the header can always be grabbed again. |
| 334 | const [position, setPosition] = useState<{ x: number; y: number } | null>(null); |
| 335 | // Null keeps the responsive default size; a value is a corner-grip resize. |
| 336 | const [size, setSize] = useState<{ width: number; height: number } | null>(null); |
| 337 | const panelRef = useRef<HTMLDivElement>(null); |
| 338 | const dragOffsetRef = useRef<{ dx: number; dy: number } | null>(null); |
| 339 | const resizeStartRef = useRef<{ |
| 340 | pointerX: number; |
| 341 | pointerY: number; |
| 342 | // Where the panel's top-left sits: the grip only moves the opposite |
| 343 | // corner, so the room to grow is measured from here. |
| 344 | left: number; |
| 345 | top: number; |
| 346 | width: number; |
| 347 | height: number; |
| 348 | } | null>(null); |
| 349 | useEffect(() => { |
| 350 | if (!open) return; |
| 351 | // A panel sized wider than the window can no longer be clamped back into |
| 352 | // view by position alone -- its right edge (close, minimize, the grip) |
| 353 | // stays off screen. So the size shrinks to fit first, then the position |
| 354 | // is re-clamped against the new size. |
| 355 | const clamp = () => { |
| 356 | const margin = 8; |
| 357 | let clampedWidth: number | undefined; |
| 358 | let clampedHeight: number | undefined; |
| 359 | setSize((current) => { |
| 360 | if (!current) return current; |
| 361 | clampedWidth = Math.max(280, Math.min(current.width, window.innerWidth - margin * 2)); |
| 362 | clampedHeight = Math.max(220, Math.min(current.height, window.innerHeight - margin * 2)); |
| 363 | return { width: clampedWidth, height: clampedHeight }; |
| 364 | }); |
| 365 | setPosition((current) => { |
| 366 | if (!current) return current; |
| 367 | const clamped = clampPosition(current.x, current.y, clampedWidth); |
| 368 | // Dragging may park the panel with only its header showing, but a |
| 369 | // window resize should pull the whole thing back into view when it |
| 370 | // fits -- otherwise the grip ends up below the fold. Minimized, the |
| 371 | // stored height is not applied (the panel hugs its header), so the |
| 372 | // rendered height is what has to fit. |
| 373 | const height = isMinimized |
| 374 | ? (panelRef.current?.offsetHeight ?? 0) |
| 375 | : (clampedHeight ?? panelRef.current?.offsetHeight ?? 0); |
| 376 | const maxY = Math.max(margin, window.innerHeight - height - margin); |
| 377 | return { x: clamped.x, y: Math.min(clamped.y, maxY) }; |
| 378 | }); |
| 379 | }; |
| 380 | window.addEventListener("resize", clamp); |
| 381 | return () => window.removeEventListener("resize", clamp); |
| 382 | }, [isMinimized, open]); |
| 383 | |
| 384 | // The draft only reaches the live app once this open has been seeded; |
| 385 | // previewing in the seeding commit would paint the previous session's |
| 386 | // colors for a frame. |
| 387 | const [isDraftSeeded, setIsDraftSeeded] = useState(false); |
| 388 | const previousOpenRef = useRef(false); |
| 389 | |
| 390 | useEffect(() => { |
| 391 | if (open && !previousOpenRef.current) { |
| 392 | // Editing works on the theme itself; creating starts from the theme |
| 393 | // that is currently in use, so tuning what you already run is an edit |
| 394 | // away instead of a rebuild from the defaults. |
| 395 | const sourceTheme = editingTheme ?? seedTheme ?? null; |
| 396 | const nextColors = getThemeEditorColorsByAppearance(); |
| 397 | const nextAppearance = sourceTheme |
| 398 | ? getThemeColorsForMode(sourceTheme, initialAppearance) |
| 399 | ? initialAppearance |
| 400 | : sourceTheme.appearance |
| 401 | : initialAppearance; |
| 402 | if (sourceTheme) { |
| 403 | nextColors[sourceTheme.appearance] = { ...sourceTheme.colors }; |
| 404 | for (const appearance of ["light", "dark"] as const) { |
| 405 | const variantColors = sourceTheme.variants?.[appearance]; |
| 406 | if (variantColors) nextColors[appearance] = { ...variantColors }; |
| 407 | } |
| 408 | } |
| 409 | |
| 410 | setName(editingTheme?.label ?? seedName ?? ""); |
| 411 | setActiveAppearance(nextAppearance); |
| 412 | // Themes saved by the guided editor carry the managed flag; anything |
| 413 | // else (imports, hand-edited files, older saves) opens in advanced mode |
| 414 | // so guided regeneration cannot silently discard hand-tuned colors. A |
| 415 | // seeded new theme follows the same rule: its palette is only safe to |
| 416 | // regenerate when the guided editor produced it. |
| 417 | setIsAdvanced(sourceTheme !== null && sourceTheme.managed !== true); |
| 418 | setSimpleColorsDirtyByAppearance({ light: false, dark: false }); |
| 419 | // An unmanaged palette needs conversion when the user opts into the |
| 420 | // guided editor. Merely revealing Advanced for a managed/default draft |
| 421 | // must stay read-only until a color changes. |
| 422 | setShouldRegenerateGuidedColors(sourceTheme !== null && sourceTheme.managed !== true); |
| 423 | setColorsByAppearance(nextColors); |
| 424 | setSelectedRole(null); |
| 425 | setUsageCount(null); |
| 426 | setIsInspecting(false); |
| 427 | setError(null); |
| 428 | setIsDraftSeeded(true); |
| 429 | } |
| 430 | if (!open && isDraftSeeded) setIsDraftSeeded(false); |
| 431 | previousOpenRef.current = open; |
| 432 | }, [editingTheme, initialAppearance, isDraftSeeded, open, seedName, seedTheme]); |
| 433 | |
| 434 | // A name an installed theme already uses combines instead of failing: |
| 435 | // creating adds the new palette to that theme, and renaming an existing |
| 436 | // theme onto it folds the edited palette in and retires the old entry — |
| 437 | // light "My Theme" plus a dark "My Theme" become one theme with both modes. |
| 438 | // Labels are matched as well as derived ids: a rename keeps a theme's |
| 439 | // original id, so its label is the only name a user can see and retype. |
| 440 | const nameTargetId = themeIdFromName(name); |
| 441 | const normalizedName = name.trim().toLowerCase(); |
| 442 | const mergeTarget = |
| 443 | normalizedName === "" |
| 444 | ? null |
| 445 | : (getCustomThemes().find( |
| 446 | (theme) => |
| 447 | theme.id !== editingTheme?.id && |
| 448 | (theme.id === nameTargetId || theme.label.trim().toLowerCase() === normalizedName), |
| 449 | ) ?? null); |
| 450 | const takenAppearances = mergeTarget ? getThemeModes(mergeTarget) : []; |
| 451 | const editableAppearances = editingTheme ? getThemeModes(editingTheme) : null; |
| 452 | |
| 453 | // The appearance a mode button would produce can be blocked two ways: the |
| 454 | // merge target already has that palette, or the theme being edited never |
| 455 | // had it (adding one is a create-with-same-name away). |
| 456 | const appearanceLockReason = (appearance: ThemeAppearance): string | null => { |
| 457 | if (editableAppearances && !editableAppearances.includes(appearance)) { |
| 458 | return `“${editingTheme?.label}” has no ${appearance} palette. Create a theme with the same name to add one.`; |
| 459 | } |
| 460 | if (!isEditing && takenAppearances.includes(appearance)) { |
| 461 | return `“${mergeTarget?.label}” already has a ${appearance} palette.`; |
| 462 | } |
| 463 | return null; |
| 464 | }; |
| 465 | |
| 466 | // Typing a name whose theme already owns the selected appearance flips the |
| 467 | // draft to the free side, so the merge affordance works without a manual |
| 468 | // toggle. Both sides taken leaves the selection alone; save is blocked with |
| 469 | // an explanation instead. |
| 470 | const mergeTargetId = mergeTarget?.id ?? null; |
| 471 | const takenAppearancesKey = takenAppearances.join(","); |
| 472 | useEffect(() => { |
| 473 | if (isEditing || mergeTargetId === null) return; |
| 474 | const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[]; |
| 475 | if (taken.length !== 1) return; |
| 476 | setActiveAppearance((current) => { |
| 477 | if (!taken.includes(current)) return current; |
| 478 | return taken[0] === "light" ? "dark" : "light"; |
| 479 | }); |
| 480 | }, [isEditing, mergeTargetId, takenAppearancesKey]); |
| 481 | |
| 482 | // The whole app wears the draft while the editor is open, so a role change |
| 483 | // is judged on the real interface rather than a miniature. The stored theme |
| 484 | // comes back when the editor closes, including on cancel. |
| 485 | useEffect(() => { |
| 486 | if (!open || !isDraftSeeded) return; |
| 487 | applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance); |
| 488 | }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]); |
| 489 | |
| 490 | useEffect(() => { |
| 491 | if (!open) return; |
| 492 | return () => { |
| 493 | restoreTheme(); |
| 494 | }; |
| 495 | }, [open, restoreTheme]); |
| 496 | |
| 497 | const updateColor = useCallback( |
| 498 | (role: ThemeColorRole, value: string) => { |
| 499 | setColorsByAppearance((current) => { |
| 500 | const nextColors = { ...current[activeAppearance], [role]: value }; |
| 501 | const shouldManageColors = |
| 502 | !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value); |
| 503 | |
| 504 | return { |
| 505 | ...current, |
| 506 | [activeAppearance]: isAdvanced |
| 507 | ? updateThemeColorFamily(activeAppearance, current[activeAppearance], role, value) |
| 508 | : shouldManageColors |
| 509 | ? getManagedEditorColors(activeAppearance, nextColors) |
| 510 | : nextColors, |
| 511 | }; |
| 512 | }); |
| 513 | if (!isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value)) { |
| 514 | setSimpleColorsDirtyByAppearance((current) => ({ |
| 515 | ...current, |
| 516 | [activeAppearance]: true, |
| 517 | })); |
| 518 | } |
| 519 | if (isAdvanced) setShouldRegenerateGuidedColors(true); |
| 520 | }, |
| 521 | [activeAppearance, isAdvanced], |
| 522 | ); |
| 523 | |
| 524 | const selectThemeRole = useCallback((role: ThemeColorRole, reveal = false) => { |
| 525 | const visibleRole = getThemeEditorColorFamily(role)?.role ?? role; |
| 526 | setSelectedRole(visibleRole); |
| 527 | if (!THEME_EDITOR_SIMPLE_ROLES.includes(visibleRole)) { |
| 528 | setIsAdvanced(true); |
| 529 | setRoleQuery(""); |
| 530 | } |
| 531 | if (!reveal) return; |
| 532 | |
| 533 | requestAnimationFrame(() => { |
| 534 | panelRef.current |
| 535 | ?.querySelector(`[data-theme-color-role="${visibleRole}"]`) |
| 536 | ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); |
| 537 | }); |
| 538 | }, []); |
| 539 | |
| 540 | const toggleThemeRole = useCallback((role: ThemeColorRole) => { |
| 541 | setSelectedRole((current) => (current === role ? null : role)); |
| 542 | }, []); |
| 543 | |
| 544 | const clearInspectorSelection = useCallback(() => { |
| 545 | setSelectedRole(null); |
| 546 | setUsageCount(null); |
| 547 | setIsInspecting(false); |
| 548 | }, []); |
| 549 | |
| 550 | const selectedHighlightRoles = selectedRole |
| 551 | ? isAdvanced |
| 552 | ? (getThemeEditorColorFamily(selectedRole)?.roles ?? [selectedRole]) |
| 553 | : THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole) |
| 554 | ? THEME_COLOR_ROLES.filter( |
| 555 | (role) => |
| 556 | colorsByAppearance[activeAppearance][role].trim().toLowerCase() === |
| 557 | colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(), |
| 558 | ) |
| 559 | : [selectedRole] |
| 560 | : []; |
| 561 | const selectedHighlightRolesKey = selectedHighlightRoles.join(","); |
| 562 | |
| 563 | useEffect(() => { |
| 564 | clearThemeInspectorHighlights(); |
| 565 | if (!open || selectedRole === null) { |
| 566 | setUsageCount(null); |
| 567 | return; |
| 568 | } |
| 569 | // Picking a new element needs the unobscured app, so suspend the existing |
| 570 | // spotlight while the picker is armed. |
| 571 | if (isInspecting) return; |
| 572 | |
| 573 | const highlightedRoles = selectedHighlightRolesKey.split(",") as Array<ThemeColorRole>; |
| 574 | const refreshHighlights = () => setUsageCount(highlightThemeRoleUsage(highlightedRoles)); |
| 575 | refreshHighlights(); |
| 576 | // A refresh snapshots computed styles for the whole tree twice, so it is |
| 577 | // throttled rather than run per frame: a streaming reply or a virtualized |
| 578 | // list mutates the DOM continuously and would otherwise stall the main |
| 579 | // thread for as long as the inspector is open. |
| 580 | const MIN_REFRESH_INTERVAL_MS = 500; |
| 581 | let refreshFrame: number | null = null; |
| 582 | let refreshTimer: ReturnType<typeof setTimeout> | null = null; |
| 583 | let lastRefreshAt = performance.now(); |
| 584 | const scheduleRefresh = () => { |
| 585 | if (refreshFrame !== null || refreshTimer !== null) return; |
| 586 | const wait = Math.max(0, MIN_REFRESH_INTERVAL_MS - (performance.now() - lastRefreshAt)); |
| 587 | const run = () => { |
| 588 | refreshFrame = null; |
| 589 | refreshTimer = null; |
| 590 | lastRefreshAt = performance.now(); |
| 591 | refreshHighlights(); |
| 592 | }; |
| 593 | if (wait === 0) refreshFrame = requestAnimationFrame(run); |
| 594 | else refreshTimer = setTimeout(run, wait); |
| 595 | }; |
| 596 | const observer = new MutationObserver((mutations) => { |
| 597 | if ( |
| 598 | mutations.every( |
| 599 | (mutation) => |
| 600 | mutation.target instanceof Element && |
| 601 | (mutation.target.closest("#theme-inspector-spotlight") || |
| 602 | mutation.target.closest("[data-theme-editor-panel]")), |
| 603 | ) |
| 604 | ) { |
| 605 | return; |
| 606 | } |
| 607 | scheduleRefresh(); |
| 608 | }); |
| 609 | observer.observe(document.body, { childList: true, subtree: true }); |
| 610 | let spotlightFrame: number | null = null; |
| 611 | const scheduleSpotlightRefresh = () => { |
| 612 | spotlightFrame ??= requestAnimationFrame(() => { |
| 613 | spotlightFrame = null; |
| 614 | refreshThemeInspectorSpotlight(); |
| 615 | }); |
| 616 | }; |
| 617 | window.addEventListener("resize", scheduleSpotlightRefresh); |
| 618 | window.addEventListener("scroll", scheduleSpotlightRefresh, true); |
| 619 | return () => { |
| 620 | observer.disconnect(); |
| 621 | if (refreshFrame !== null) cancelAnimationFrame(refreshFrame); |
| 622 | if (refreshTimer !== null) clearTimeout(refreshTimer); |
| 623 | if (spotlightFrame !== null) cancelAnimationFrame(spotlightFrame); |
| 624 | window.removeEventListener("resize", scheduleSpotlightRefresh); |
| 625 | window.removeEventListener("scroll", scheduleSpotlightRefresh, true); |
| 626 | clearThemeInspectorHighlights(); |
| 627 | }; |
| 628 | }, [isInspecting, open, selectedHighlightRolesKey, selectedRole]); |
| 629 | |
| 630 | useEffect(() => { |
| 631 | if (!open || !isInspecting) { |
| 632 | clearThemeInspectorHover(); |
| 633 | return; |
| 634 | } |
| 635 | |
| 636 | let shouldDisarmAfterClick = false; |
| 637 | let hoverTarget: Element | null = null; |
| 638 | let hoverInspection: ThemeElementInspection | null = null; |
| 639 | let hoverTimer: number | null = null; |
| 640 | let hoverFrame: number | null = null; |
| 641 | const clearHoverTimer = () => { |
| 642 | if (hoverTimer === null) return; |
| 643 | window.clearTimeout(hoverTimer); |
| 644 | hoverTimer = null; |
| 645 | }; |
| 646 | const clearHover = () => { |
| 647 | clearHoverTimer(); |
| 648 | hoverTarget = null; |
| 649 | hoverInspection = null; |
| 650 | clearThemeInspectorHover(); |
| 651 | }; |
| 652 | const showInspection = (inspection: ThemeElementInspection) => { |
| 653 | hoverInspection = inspection; |
| 654 | showThemeInspectorHover( |
| 655 | inspection, |
| 656 | getThemeEditorColorFamily(inspection.role)?.label ?? getThemeRoleLabel(inspection.role), |
| 657 | ); |
| 658 | }; |
| 659 | const handlePointerOver = (event: PointerEvent) => { |
| 660 | const target = event.target; |
| 661 | if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) { |
| 662 | clearHover(); |
| 663 | return; |
| 664 | } |
| 665 | |
| 666 | clearHoverTimer(); |
| 667 | hoverTarget = target; |
| 668 | hoverInspection = null; |
| 669 | const utilityInspection = inspectThemeRoleFromUtilitiesAtElement(target); |
| 670 | if (utilityInspection) { |
| 671 | showInspection(utilityInspection); |
| 672 | return; |
| 673 | } |
| 674 | |
| 675 | clearThemeInspectorHover(); |
| 676 | hoverTimer = window.setTimeout(() => { |
| 677 | hoverTimer = null; |
| 678 | if (hoverTarget !== target || !target.isConnected) return; |
| 679 | const inspection = inspectThemeRoleAtElement(target); |
| 680 | if (inspection) showInspection(inspection); |
| 681 | }, 140); |
| 682 | }; |
| 683 | const handlePointerOut = (event: PointerEvent) => { |
| 684 | if (event.relatedTarget === null) clearHover(); |
| 685 | }; |
| 686 | const handlePointerDown = (event: PointerEvent) => { |
| 687 | const target = event.target; |
| 688 | if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) return; |
| 689 | event.preventDefault(); |
| 690 | event.stopPropagation(); |
| 691 | clearHoverTimer(); |
| 692 | const inspection = |
| 693 | hoverTarget === target && hoverInspection |
| 694 | ? hoverInspection |
| 695 | : inspectThemeRoleAtElement(target); |
| 696 | if (!inspection) return; |
| 697 | clearHover(); |
| 698 | selectThemeRole(inspection.role, true); |
| 699 | shouldDisarmAfterClick = true; |
| 700 | }; |
| 701 | const blockInspectedClick = (event: MouseEvent) => { |
| 702 | const target = event.target; |
| 703 | if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) return; |
| 704 | event.preventDefault(); |
| 705 | event.stopPropagation(); |
| 706 | if (shouldDisarmAfterClick) setIsInspecting(false); |
| 707 | shouldDisarmAfterClick = false; |
| 708 | }; |
| 709 | const cancelInspection = (event: KeyboardEvent) => { |
| 710 | if (event.key !== "Escape") return; |
| 711 | event.preventDefault(); |
| 712 | clearHover(); |
| 713 | clearInspectorSelection(); |
| 714 | }; |
| 715 | const refreshHover = () => { |
| 716 | if (!hoverInspection) return; |
| 717 | hoverFrame ??= requestAnimationFrame(() => { |
| 718 | hoverFrame = null; |
| 719 | if (hoverInspection) { |
| 720 | showThemeInspectorHover( |
| 721 | hoverInspection, |
| 722 | getThemeEditorColorFamily(hoverInspection.role)?.label ?? |
| 723 | getThemeRoleLabel(hoverInspection.role), |
| 724 | ); |
| 725 | } |
| 726 | }); |
| 727 | }; |
| 728 | const clearHoverOnScroll = () => clearHover(); |
| 729 | |
| 730 | document.addEventListener("pointerover", handlePointerOver, true); |
| 731 | document.addEventListener("pointerout", handlePointerOut, true); |
| 732 | document.addEventListener("pointerdown", handlePointerDown, true); |
| 733 | document.addEventListener("click", blockInspectedClick, true); |
| 734 | document.addEventListener("keydown", cancelInspection, true); |
| 735 | window.addEventListener("resize", refreshHover); |
| 736 | window.addEventListener("scroll", clearHoverOnScroll, true); |
| 737 | return () => { |
| 738 | document.removeEventListener("pointerover", handlePointerOver, true); |
| 739 | document.removeEventListener("pointerout", handlePointerOut, true); |
| 740 | document.removeEventListener("pointerdown", handlePointerDown, true); |
| 741 | document.removeEventListener("click", blockInspectedClick, true); |
| 742 | document.removeEventListener("keydown", cancelInspection, true); |
| 743 | window.removeEventListener("resize", refreshHover); |
| 744 | window.removeEventListener("scroll", clearHoverOnScroll, true); |
| 745 | clearHoverTimer(); |
| 746 | if (hoverFrame !== null) cancelAnimationFrame(hoverFrame); |
| 747 | clearThemeInspectorHover(); |
| 748 | }; |
| 749 | }, [clearInspectorSelection, isInspecting, open, selectThemeRole]); |
| 750 | |
| 751 | const handleAdvancedChange = useCallback( |
| 752 | (checked: boolean) => { |
| 753 | setIsAdvanced(checked); |
| 754 | if (checked) return; |
| 755 | if (selectedRole && !THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)) { |
| 756 | setSelectedRole(null); |
| 757 | } |
| 758 | if (!shouldRegenerateGuidedColors) return; |
| 759 | |
| 760 | // Regenerate every appearance the theme will save, not just the visible |
| 761 | // one, so the palettes shown after toggling match what gets saved. |
| 762 | const managedAppearances: ReadonlyArray<ThemeAppearance> = |
| 763 | editingTheme && getThemeModes(editingTheme).length > 1 |
| 764 | ? ["light", "dark"] |
| 765 | : [activeAppearance]; |
| 766 | setSimpleColorsDirtyByAppearance((current) => { |
| 767 | const next = { ...current }; |
| 768 | for (const appearance of managedAppearances) next[appearance] = true; |
| 769 | return next; |
| 770 | }); |
| 771 | setColorsByAppearance((current) => { |
| 772 | const next = { ...current }; |
| 773 | for (const appearance of managedAppearances) { |
| 774 | next[appearance] = getManagedEditorColors(appearance, current[appearance]); |
| 775 | } |
| 776 | return next; |
| 777 | }); |
| 778 | setShouldRegenerateGuidedColors(false); |
| 779 | }, |
| 780 | [activeAppearance, editingTheme, selectedRole, shouldRegenerateGuidedColors], |
| 781 | ); |
| 782 | |
| 783 | const handleSubmit = () => { |
| 784 | if (!name.trim()) { |
| 785 | setError("Name your theme first."); |
| 786 | return; |
| 787 | } |
| 788 | |
| 789 | try { |
| 790 | // Only regenerate palettes the user actually touched in guided mode, so |
| 791 | // untouched appearances save exactly what the editor displayed. |
| 792 | const colorsForSave = !isAdvanced |
| 793 | ? { |
| 794 | light: simpleColorsDirtyByAppearance.light |
| 795 | ? getManagedEditorColors("light", colorsByAppearance.light) |
| 796 | : colorsByAppearance.light, |
| 797 | dark: simpleColorsDirtyByAppearance.dark |
| 798 | ? getManagedEditorColors("dark", colorsByAppearance.dark) |
| 799 | : colorsByAppearance.dark, |
| 800 | } |
| 801 | : colorsByAppearance; |
| 802 | |
| 803 | let savedTheme: ThemeDefinition; |
| 804 | let mergedAppearance: ThemeAppearance | null = null; |
| 805 | let retiredTheme: ThemeDefinition | null = null; |
| 806 | if (editingTheme && mergeTarget) { |
| 807 | // Renamed onto another installed theme: this theme's palettes fold |
| 808 | // into it and the edited entry retires, so both cards become one. |
| 809 | // Colliding palettes cannot merge — neither side should be silently |
| 810 | // overwritten. |
| 811 | const editedModes = getThemeModes(editingTheme); |
| 812 | const collision = editedModes.find((mode) => takenAppearances.includes(mode)); |
| 813 | if (collision) { |
| 814 | setError(`“${mergeTarget.label}” already has a ${collision} palette. Pick another name.`); |
| 815 | return; |
| 816 | } |
| 817 | mergedAppearance = editedModes[0] ?? null; |
| 818 | savedTheme = updateCustomTheme({ |
| 819 | ...parseThemeFile({ |
| 820 | version: THEME_FILE_VERSION, |
| 821 | id: mergeTarget.id, |
| 822 | name: mergeTarget.label, |
| 823 | appearance: mergeTarget.appearance, |
| 824 | colors: mergeTarget.colors, |
| 825 | variants: { |
| 826 | ...mergeTarget.variants, |
| 827 | ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])), |
| 828 | }, |
| 829 | ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), |
| 830 | }), |
| 831 | ...(mergeTarget.collection ? { collection: mergeTarget.collection } : {}), |
| 832 | }); |
| 833 | retiredTheme = editingTheme; |
| 834 | try { |
| 835 | removeCustomTheme(editingTheme.id); |
| 836 | } catch (cause) { |
| 837 | // The merge already persisted. Leaving it while the edited theme |
| 838 | // survives would collide on every retry, so the target goes back to |
| 839 | // its pre-merge palettes before the failure surfaces. |
| 840 | try { |
| 841 | updateCustomTheme(mergeTarget); |
| 842 | } catch { |
| 843 | // Storage is failing wholesale; the rethrow below reports it. |
| 844 | } |
| 845 | throw cause; |
| 846 | } |
| 847 | } else if (editingTheme) { |
| 848 | const baseAppearance = editingTheme.appearance; |
| 849 | const variantAppearance = baseAppearance === "light" ? "dark" : "light"; |
| 850 | savedTheme = updateCustomTheme({ |
| 851 | ...parseThemeFile({ |
| 852 | version: THEME_FILE_VERSION, |
| 853 | id: editingTheme.id, |
| 854 | name, |
| 855 | appearance: baseAppearance, |
| 856 | colors: colorsForSave[baseAppearance], |
| 857 | ...(getThemeModes(editingTheme).length > 1 |
| 858 | ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } } |
| 859 | : {}), |
| 860 | ...(isAdvanced ? {} : { managed: true }), |
| 861 | }), |
| 862 | ...(editingTheme.collection ? { collection: editingTheme.collection } : {}), |
| 863 | }); |
| 864 | } else if (mergeTarget) { |
| 865 | if (takenAppearances.includes(activeAppearance)) { |
| 866 | setError( |
| 867 | `“${mergeTarget.label}” already has light and dark palettes. Pick another name.`, |
| 868 | ); |
| 869 | return; |
| 870 | } |
| 871 | // The new palette joins the existing theme as its other mode; its |
| 872 | // stored palettes are untouched. The guided (managed) flag only |
| 873 | // survives when every palette in the theme came from the guided |
| 874 | // editor. |
| 875 | mergedAppearance = activeAppearance; |
| 876 | savedTheme = updateCustomTheme({ |
| 877 | ...parseThemeFile({ |
| 878 | version: THEME_FILE_VERSION, |
| 879 | id: mergeTarget.id, |
| 880 | name: mergeTarget.label, |
| 881 | appearance: mergeTarget.appearance, |
| 882 | colors: mergeTarget.colors, |
| 883 | variants: { |
| 884 | ...mergeTarget.variants, |
| 885 | [activeAppearance]: colorsForSave[activeAppearance], |
| 886 | }, |
| 887 | ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), |
| 888 | }), |
| 889 | ...(mergeTarget.collection ? { collection: mergeTarget.collection } : {}), |
| 890 | }); |
| 891 | } else { |
| 892 | savedTheme = installCustomTheme( |
| 893 | parseThemeFile({ |
| 894 | version: THEME_FILE_VERSION, |
| 895 | name, |
| 896 | appearance: activeAppearance, |
| 897 | colors: colorsForSave[activeAppearance], |
| 898 | ...(isAdvanced ? {} : { managed: true }), |
| 899 | }), |
| 900 | ); |
| 901 | } |
| 902 | if ( |
| 903 | !onSaved(savedTheme, { |
| 904 | created: editingTheme === null && mergedAppearance === null, |
| 905 | ...(mergedAppearance ? { mergedAppearance } : {}), |
| 906 | }) |
| 907 | ) { |
| 908 | if (!editingTheme && mergedAppearance === null) { |
| 909 | // Roll the install back so a retry can run it again instead of |
| 910 | // failing on the already-taken theme id. |
| 911 | try { |
| 912 | removeCustomTheme(savedTheme.id); |
| 913 | } catch { |
| 914 | // Storage is failing wholesale; the error below covers it. |
| 915 | } |
| 916 | } else if (mergeTarget && mergedAppearance !== null) { |
| 917 | // Put the pre-merge definitions back for the same reason. |
| 918 | try { |
| 919 | updateCustomTheme(mergeTarget); |
| 920 | if (retiredTheme) installCustomTheme(retiredTheme); |
| 921 | } catch { |
| 922 | // Storage is failing wholesale; the error below covers it. |
| 923 | } |
| 924 | } |
| 925 | setError("Theme saved, but it could not be made active. Try again."); |
| 926 | return; |
| 927 | } |
| 928 | onOpenChange(false); |
| 929 | } catch (cause) { |
| 930 | setError( |
| 931 | cause instanceof Error |
| 932 | ? cause.message |
| 933 | : isEditing |
| 934 | ? "Could not save the theme." |
| 935 | : "Could not create the theme.", |
| 936 | ); |
| 937 | } |
| 938 | }; |
| 939 | |
| 940 | const renderNameField = () => ( |
| 941 | <label className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-center gap-3"> |
| 942 | <span className="text-sm font-medium">Theme name</span> |
| 943 | <Input |
| 944 | autoFocus |
| 945 | size="sm" |
| 946 | onChange={(event) => { |
| 947 | setName(event.currentTarget.value); |
| 948 | // Most save failures are name collisions; retyping is the fix, so |
| 949 | // the stale message goes with the old name. |
| 950 | setError(null); |
| 951 | }} |
| 952 | placeholder={isEditing ? "Theme name" : "e.g. Aurora"} |
| 953 | value={name} |
| 954 | /> |
| 955 | </label> |
| 956 | ); |
| 957 | |
| 958 | const renderAppearanceButton = (appearance: ThemeAppearance) => { |
| 959 | const lockReason = appearanceLockReason(appearance); |
| 960 | // A locked mode stays hoverable so the tooltip can say why it is off; |
| 961 | // a real disabled attribute would swallow the pointer events. |
| 962 | const button = ( |
| 963 | <Toggle aria-disabled={lockReason !== null} value={appearance}> |
| 964 | {appearance === "light" ? "Light" : "Dark"} |
| 965 | </Toggle> |
| 966 | ); |
| 967 | if (lockReason === null) return button; |
| 968 | return ( |
| 969 | <Tooltip> |
| 970 | <TooltipTrigger render={button} /> |
| 971 | <TooltipPopup>{lockReason}</TooltipPopup> |
| 972 | </Tooltip> |
| 973 | ); |
| 974 | }; |
| 975 | |
| 976 | const renderAppearanceButtons = () => ( |
| 977 | <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-center gap-3"> |
| 978 | <span className="text-sm font-medium">Appearance</span> |
| 979 | <ToggleGroup |
| 980 | aria-label="Theme appearance" |
| 981 | variant="segmented" |
| 982 | value={[activeAppearance]} |
| 983 | onValueChange={(next) => { |
| 984 | const appearance = next[0]; |
| 985 | if ( |
| 986 | (appearance === "light" || appearance === "dark") && |
| 987 | appearanceLockReason(appearance) === null |
| 988 | ) { |
| 989 | setActiveAppearance(appearance); |
| 990 | } |
| 991 | }} |
| 992 | > |
| 993 | {renderAppearanceButton("light")} |
| 994 | {renderAppearanceButton("dark")} |
| 995 | </ToggleGroup> |
| 996 | </div> |
| 997 | ); |
| 998 | |
| 999 | const renderColorsHeader = () => ( |
| 1000 | <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-start gap-3"> |
| 1001 | <div> |
| 1002 | <h3 className="text-sm font-medium">Colors</h3> |
| 1003 | {isAdvanced ? null : ( |
| 1004 | <p className="text-xs text-muted-foreground">Two colors, rest derived</p> |
| 1005 | )} |
| 1006 | </div> |
| 1007 | <div className="flex min-w-0 items-start gap-3"> |
| 1008 | {isAdvanced ? ( |
| 1009 | <Input |
| 1010 | aria-label="Filter colors" |
| 1011 | className="min-w-0 flex-1" |
| 1012 | onChange={(event) => setRoleQuery(event.currentTarget.value)} |
| 1013 | placeholder="Filter colors" |
| 1014 | size="sm" |
| 1015 | value={roleQuery} |
| 1016 | /> |
| 1017 | ) : null} |
| 1018 | <label className="ml-auto flex shrink-0 cursor-pointer items-center gap-2 pt-0.5 text-sm font-medium"> |
| 1019 | <span>Advanced</span> |
| 1020 | <Switch |
| 1021 | aria-label="Use advanced theme colors" |
| 1022 | checked={isAdvanced} |
| 1023 | onCheckedChange={(checked) => handleAdvancedChange(Boolean(checked))} |
| 1024 | /> |
| 1025 | </label> |
| 1026 | </div> |
| 1027 | </div> |
| 1028 | ); |
| 1029 | |
| 1030 | const renderRoleFields = ( |
| 1031 | families: ReadonlyArray<ThemeEditorColorFamily>, |
| 1032 | gridClassName = "grid gap-2 sm:grid-cols-2", |
| 1033 | ) => ( |
| 1034 | <div className={gridClassName}> |
| 1035 | {families.map((family) => ( |
| 1036 | <ThemeColorField |
| 1037 | key={family.id} |
| 1038 | label={family.label} |
| 1039 | onChange={updateColor} |
| 1040 | onSelect={selectThemeRole} |
| 1041 | onToggleSelected={toggleThemeRole} |
| 1042 | role={family.role} |
| 1043 | selected={selectedRole === family.role} |
| 1044 | value={colorsByAppearance[activeAppearance][family.role]} |
| 1045 | /> |
| 1046 | ))} |
| 1047 | </div> |
| 1048 | ); |
| 1049 | |
| 1050 | const renderColorFields = () => { |
| 1051 | const query = roleQuery.trim().toLowerCase(); |
| 1052 | const groups = THEME_EDITOR_ROLE_GROUPS.map((group) => ({ |
| 1053 | ...group, |
| 1054 | families: group.families.filter( |
| 1055 | (family) => |
| 1056 | !query || |
| 1057 | [family.label, ...family.roles.map((role) => getThemeRoleLabel(role))] |
| 1058 | .join(" ") |
| 1059 | .toLowerCase() |
| 1060 | .includes(query), |
| 1061 | ), |
| 1062 | })).filter((group) => group.families.length > 0); |
| 1063 | return isAdvanced ? ( |
| 1064 | <div className="space-y-5"> |
| 1065 | {groups.map((group) => ( |
| 1066 | <section className="space-y-2" key={group.id}> |
| 1067 | <h4 className="text-sm font-medium text-foreground">{group.title}</h4> |
| 1068 | {renderRoleFields(group.families, "grid gap-1")} |
| 1069 | </section> |
| 1070 | ))} |
| 1071 | {groups.length === 0 ? <p className="text-xs text-muted-foreground">No matches.</p> : null} |
| 1072 | </div> |
| 1073 | ) : ( |
| 1074 | <div className="grid gap-1"> |
| 1075 | {THEME_EDITOR_SIMPLE_ROLES.map((role) => ( |
| 1076 | <ThemeColorField |
| 1077 | key={role} |
| 1078 | onChange={updateColor} |
| 1079 | onSelect={selectThemeRole} |
| 1080 | onToggleSelected={toggleThemeRole} |
| 1081 | role={role} |
| 1082 | label={role === "canvas" ? "Background" : "Accent"} |
| 1083 | selected={selectedRole === role} |
| 1084 | value={colorsByAppearance[activeAppearance][role]} |
| 1085 | /> |
| 1086 | ))} |
| 1087 | </div> |
| 1088 | ); |
| 1089 | }; |
| 1090 | |
| 1091 | const clampPosition = (x: number, y: number, widthOverride?: number) => { |
| 1092 | const panel = panelRef.current; |
| 1093 | const margin = 8; |
| 1094 | // The caller passes a width when it has just shrunk the panel: the DOM |
| 1095 | // still reports the old one until React commits. |
| 1096 | const width = widthOverride ?? panel?.offsetWidth ?? 0; |
| 1097 | return { |
| 1098 | x: Math.min(Math.max(x, margin), Math.max(margin, window.innerWidth - width - margin)), |
| 1099 | // Keep at least the header on screen even when dragged far down. |
| 1100 | y: Math.min(Math.max(y, margin), Math.max(margin, window.innerHeight - 48)), |
| 1101 | }; |
| 1102 | }; |
| 1103 | |
| 1104 | const handleDragPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => { |
| 1105 | // Buttons in the header keep their own behavior. |
| 1106 | if ((event.target as HTMLElement).closest("button, input, a")) return; |
| 1107 | const rect = panelRef.current?.getBoundingClientRect(); |
| 1108 | if (!rect) return; |
| 1109 | dragOffsetRef.current = { dx: event.clientX - rect.x, dy: event.clientY - rect.y }; |
| 1110 | event.currentTarget.setPointerCapture(event.pointerId); |
| 1111 | }; |
| 1112 | |
| 1113 | const handleDragPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => { |
| 1114 | const offset = dragOffsetRef.current; |
| 1115 | if (!offset) return; |
| 1116 | setPosition(clampPosition(event.clientX - offset.dx, event.clientY - offset.dy)); |
| 1117 | }; |
| 1118 | |
| 1119 | const endDrag = () => { |
| 1120 | dragOffsetRef.current = null; |
| 1121 | }; |
| 1122 | |
| 1123 | const handleResizePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => { |
| 1124 | const rect = panelRef.current?.getBoundingClientRect(); |
| 1125 | if (!rect) return; |
| 1126 | event.preventDefault(); |
| 1127 | // The grip drags the bottom-right corner, so the top-left must hold |
| 1128 | // still; the default parking spot is anchored bottom-right and would |
| 1129 | // slide, so it converts to an explicit position first. |
| 1130 | if (position === null) setPosition(clampPosition(rect.x, rect.y)); |
| 1131 | resizeStartRef.current = { |
| 1132 | pointerX: event.clientX, |
| 1133 | pointerY: event.clientY, |
| 1134 | left: rect.x, |
| 1135 | top: rect.y, |
| 1136 | width: rect.width, |
| 1137 | height: rect.height, |
| 1138 | }; |
| 1139 | event.currentTarget.setPointerCapture(event.pointerId); |
| 1140 | }; |
| 1141 | |
| 1142 | const handleResizePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => { |
| 1143 | const start = resizeStartRef.current; |
| 1144 | if (!start) return; |
| 1145 | const margin = 8; |
| 1146 | const MIN_WIDTH = 280; |
| 1147 | const MIN_HEIGHT = 220; |
| 1148 | // Grow only into the space right of and below the panel's own corner, |
| 1149 | // otherwise a panel parked away from the top-left pushes its far edges |
| 1150 | // (and this grip) off screen. |
| 1151 | const maxWidth = Math.max(MIN_WIDTH, window.innerWidth - margin - start.left); |
| 1152 | const maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - margin - start.top); |
| 1153 | setSize({ |
| 1154 | width: Math.min(Math.max(start.width + event.clientX - start.pointerX, MIN_WIDTH), maxWidth), |
| 1155 | height: Math.min( |
| 1156 | Math.max(start.height + event.clientY - start.pointerY, MIN_HEIGHT), |
| 1157 | maxHeight, |
| 1158 | ), |
| 1159 | }); |
| 1160 | }; |
| 1161 | |
| 1162 | const endResize = () => { |
| 1163 | resizeStartRef.current = null; |
| 1164 | }; |
| 1165 | |
| 1166 | return ( |
| 1167 | <div |
| 1168 | aria-label={isEditing ? "Edit theme" : "Create theme"} |
| 1169 | className={cn( |
| 1170 | "dialog-glass fixed z-[110] flex max-h-[min(42rem,calc(100dvh-6rem))] w-[min(26rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border text-popover-foreground", |
| 1171 | position === null && "bottom-4 right-4", |
| 1172 | isMinimized && "max-h-none", |
| 1173 | )} |
| 1174 | data-theme-editor-panel |
| 1175 | ref={panelRef} |
| 1176 | role="dialog" |
| 1177 | style={{ |
| 1178 | ...(position ? { left: position.x, top: position.y } : {}), |
| 1179 | ...(size ? { width: size.width } : {}), |
| 1180 | // A chosen height only applies expanded; minimized keeps hugging the |
| 1181 | // header. The viewport stays the ceiling either way. |
| 1182 | ...(size && !isMinimized ? { height: size.height, maxHeight: "calc(100dvh - 1rem)" } : {}), |
| 1183 | }} |
| 1184 | > |
| 1185 | <div |
| 1186 | className="flex cursor-grab touch-none select-none items-center gap-1 border-b border-border/70 px-3 py-2 active:cursor-grabbing" |
| 1187 | onPointerCancel={endDrag} |
| 1188 | onPointerDown={handleDragPointerDown} |
| 1189 | onPointerMove={handleDragPointerMove} |
| 1190 | onPointerUp={endDrag} |
| 1191 | > |
| 1192 | <div className="flex min-w-0 flex-1 items-baseline gap-2"> |
| 1193 | <h2 className="shrink-0 truncate text-sm font-medium"> |
| 1194 | {isEditing ? "Edit theme" : "Create theme"} |
| 1195 | </h2> |
| 1196 | {isMinimized ? null : ( |
| 1197 | <p className="truncate text-xs text-muted-foreground"> |
| 1198 | {isInspecting |
| 1199 | ? "Select an element · Esc to cancel" |
| 1200 | : selectedRole |
| 1201 | ? `${isAdvanced ? (getThemeEditorColorFamily(selectedRole)?.label ?? getThemeRoleLabel(selectedRole)) : getThemeRoleLabel(selectedRole)} · ${usageCount ?? 0} ${usageCount === 1 ? "use" : "uses"}` |
| 1202 | : "Select a color below"} |
| 1203 | </p> |
| 1204 | )} |
| 1205 | </div> |
| 1206 | <Tooltip> |
| 1207 | <TooltipTrigger |
| 1208 | render={ |
| 1209 | <Button |
| 1210 | aria-label={isInspecting ? "Cancel inspecting app colors" : "Inspect app colors"} |
| 1211 | aria-pressed={isInspecting} |
| 1212 | size="xs" |
| 1213 | variant={isInspecting ? "secondary" : "ghost"} |
| 1214 | onClick={() => { |
| 1215 | if (isInspecting) { |
| 1216 | clearInspectorSelection(); |
| 1217 | return; |
| 1218 | } |
| 1219 | setIsInspecting(true); |
| 1220 | }} |
| 1221 | > |
| 1222 | <MousePointer2Icon /> |
| 1223 | {isInspecting ? "Cancel" : "Inspect"} |
| 1224 | </Button> |
| 1225 | } |
| 1226 | /> |
| 1227 | <TooltipPopup data-theme-editor-panel=""> |
| 1228 | {isInspecting ? "Cancel and clear the selection" : "Pick a color from the app"} |
| 1229 | </TooltipPopup> |
| 1230 | </Tooltip> |
| 1231 | <Button |
| 1232 | aria-label={isMinimized ? "Expand the theme editor" : "Minimize the theme editor"} |
| 1233 | size="icon-xs" |
| 1234 | variant="ghost" |
| 1235 | onClick={() => setIsMinimized(!isMinimized)} |
| 1236 | > |
| 1237 | <MorphIcon icon={isMinimized ? ChevronUp : ChevronDown} /> |
| 1238 | </Button> |
| 1239 | <Button |
| 1240 | aria-label="Close the theme editor" |
| 1241 | size="icon-xs" |
| 1242 | variant="ghost" |
| 1243 | onClick={() => onOpenChange(false)} |
| 1244 | > |
| 1245 | <XIcon /> |
| 1246 | </Button> |
| 1247 | </div> |
| 1248 | |
| 1249 | {isMinimized ? null : ( |
| 1250 | <> |
| 1251 | <div className="min-h-0 flex-1 space-y-3 overflow-y-auto py-3 pl-3 pr-1.5"> |
| 1252 | {renderNameField()} |
| 1253 | {/* Inline and above the color list: the panel scrolls, and an |
| 1254 | error parked below every role would go unseen. */} |
| 1255 | {error ? ( |
| 1256 | <p aria-live="polite" className="text-sm text-destructive"> |
| 1257 | {error} |
| 1258 | </p> |
| 1259 | ) : null} |
| 1260 | {renderAppearanceButtons()} |
| 1261 | <div className="space-y-3"> |
| 1262 | {renderColorsHeader()} |
| 1263 | {renderColorFields()} |
| 1264 | </div> |
| 1265 | </div> |
| 1266 | <div className="flex items-center justify-end gap-2 border-t border-border/70 px-3 py-2"> |
| 1267 | <Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}> |
| 1268 | Cancel |
| 1269 | </Button> |
| 1270 | <Button disabled={!name.trim()} size="sm" onClick={handleSubmit}> |
| 1271 | {isEditing ? ( |
| 1272 | mergeTarget ? ( |
| 1273 | `Merge into “${mergeTarget.label}”` |
| 1274 | ) : ( |
| 1275 | "Save changes" |
| 1276 | ) |
| 1277 | ) : mergeTarget ? ( |
| 1278 | <> |
| 1279 | <PlusIcon /> |
| 1280 | {`Add ${activeAppearance} palette`} |
| 1281 | </> |
| 1282 | ) : ( |
| 1283 | <> |
| 1284 | <PaintbrushIcon /> |
| 1285 | Create theme |
| 1286 | </> |
| 1287 | )} |
| 1288 | </Button> |
| 1289 | </div> |
| 1290 | <div |
| 1291 | aria-hidden |
| 1292 | className="absolute bottom-0 right-0 z-10 flex size-5 cursor-se-resize touch-none select-none items-end justify-end p-1 text-muted-foreground/70" |
| 1293 | onPointerCancel={endResize} |
| 1294 | onPointerDown={handleResizePointerDown} |
| 1295 | onPointerMove={handleResizePointerMove} |
| 1296 | onPointerUp={endResize} |
| 1297 | > |
| 1298 | <svg |
| 1299 | className="size-2.5" |
| 1300 | fill="none" |
| 1301 | stroke="currentColor" |
| 1302 | strokeLinecap="round" |
| 1303 | strokeWidth="1.2" |
| 1304 | viewBox="0 0 8 8" |
| 1305 | > |
| 1306 | <path d="M7 1 1 7M7 4.5 4.5 7" /> |
| 1307 | </svg> |
| 1308 | </div> |
| 1309 | </> |
| 1310 | )} |
| 1311 | </div> |
| 1312 | ); |
| 1313 | } |