1import { MousePointer2Icon, PaintbrushIcon, PlusIcon, XIcon } from "lucide-react";
2import { ChevronDown, ChevronUp } from "lucide";
3import {
4 useCallback,
5 useEffect,
6 useRef,
7 useState,
8 type PointerEvent as ReactPointerEvent,
9} from "react";
10import {
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";
31import { cn } from "../../lib/utils";
32import { Button } from "../ui/button";
33import { MorphIcon } from "~/components/MorphIcon";
34import { Input } from "../ui/input";
35import { Switch } from "../ui/switch";
36import { Toggle, ToggleGroup } from "../ui/toggle-group";
37import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
38import { getThemeRoleLabel, ThemeColorField } from "./ThemeColorPicker";
39import {
40 clearThemeInspectorHover,
41 clearThemeInspectorHighlights,
42 highlightThemeRoleUsage,
43 inspectThemeRoleAtElement,
44 inspectThemeRoleFromUtilitiesAtElement,
45 refreshThemeInspectorSpotlight,
46 showThemeInspectorHover,
47 type ThemeElementInspection,
48} from "./themeInspector";
49
50const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray<ThemeColorRole> = ["canvas", "accent"];
51
52type ThemeEditorColorFamily = Readonly<{
53 id: string;
54 label: string;
55 role: ThemeColorRole;
56 roles: ReadonlyArray<ThemeColorRole>;
57}>;
58
59const 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
239const THEME_EDITOR_COLOR_FAMILIES = THEME_EDITOR_ROLE_GROUPS.flatMap((group) => group.families);
240const 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
246function getThemeEditorColorFamily(role: ThemeColorRole): ThemeEditorColorFamily | null {
247 return THEME_EDITOR_COLOR_FAMILY_BY_ROLE.get(role) ?? null;
248}
249
250type ThemeEditorColors = ThemeColors;
251type 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.
256function getThemeEditorDefaults(appearance: ThemeAppearance): ThemeEditorColors {
257 return { ...getStandardThemeColors(appearance) };
258}
259
260function getThemeEditorColorsByAppearance(): ThemeEditorColorsByAppearance {
261 return {
262 light: getThemeEditorDefaults("light"),
263 dark: getThemeEditorDefaults("dark"),
264 };
265}
266
267function isThemeEditorColor(value: string): boolean {
268 return isThemeColor(value.trim());
269}
270
271function 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
285export 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}