1import {
2 CheckIcon,
3 CopyIcon,
4 MoonIcon,
5 PaintbrushIcon,
6 PenLineIcon,
7 PlusIcon,
8 SunIcon,
9 Trash2Icon,
10 UploadIcon,
11} from "lucide-react";
12import { useCallback, useEffect, useState, type ReactElement } from "react";
13import { useEnvironmentThemeDefinitions } from "../../hooks/useEnvironmentTheme";
14import { readThemeHalvesRaw } from "../../hooks/useTheme";
15import { cn } from "../../lib/utils";
16import {
17 getThemeDefinition,
18 singleAppearanceOf,
19 getThemeModes,
20 removeCustomThemes,
21 serializeThemeFile,
22 type ThemeAppearance,
23 type ThemeDefinition,
24 type ThemeHalves,
25 T3_CHAT_THEME,
26 EMBER_THEME,
27 GROVE_THEME,
28 IRIS_THEME,
29 OCEAN_THEME,
30} from "../../themePalette";
31import {
32 AlertDialog,
33 AlertDialogClose,
34 AlertDialogDescription,
35 AlertDialogFooter,
36 AlertDialogHeader,
37 AlertDialogPopup,
38 AlertDialogTitle,
39} from "../ui/alert-dialog";
40import { Button } from "../ui/button";
41import { stackedThreadToast, toastManager } from "../ui/toast";
42import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
43import { ThemeImportDialog } from "./ThemeImportDialog";
44import { searchableSetting } from "./settingsSearch";
45import { useThemeEditorStore } from "./themeEditorStore";
46import {
47 STANDARD_THEME_CARDS,
48 getThemeCardDefinition,
49 previewColorsOf,
50 ThemePreviewCircles,
51 ThemePreviewCircle,
52 type ThemeCardDefinition,
53 type ThemeMode,
54} from "./ThemePreviewCircles";
55import { ThemeWireframe } from "./ThemeWireframe";
56
57const MAINTAINER_THEMES: ReadonlyArray<ThemeDefinition> = [
58 T3_CHAT_THEME,
59 GROVE_THEME,
60 OCEAN_THEME,
61 EMBER_THEME,
62 IRIS_THEME,
63];
64
65function collectionVariantLabels(themes: ReadonlyArray<ThemeDefinition>): ReadonlyArray<string> {
66 if (themes.length === 0) return [];
67 const words = themes.map((theme) => theme.label.trim().split(/\s+/));
68 const firstWords = words[0]!;
69 const sharedWordCount = firstWords.findIndex((word, index) =>
70 words.some((labelWords) => labelWords[index]?.toLocaleLowerCase() !== word.toLocaleLowerCase()),
71 );
72 const prefixLength = sharedWordCount === -1 ? firstWords.length - 1 : sharedWordCount;
73
74 return themes.map((theme, index) => {
75 const shortLabel = words[index]?.slice(Math.max(0, prefixLength)).join(" ").trim();
76 return shortLabel || theme.label;
77 });
78}
79
80function downloadThemeFile(filename: string, contents: string): void {
81 const url = URL.createObjectURL(new Blob([contents], { type: "application/json" }));
82 const anchor = document.createElement("a");
83 anchor.href = url;
84 anchor.download = filename;
85 anchor.click();
86 // Revoking synchronously can abort the download in some browsers; give the
87 // browser time to open the stream first.
88 setTimeout(() => URL.revokeObjectURL(url), 30_000);
89}
90
91function ThemeVariantTooltip({ label, children }: { label: string; children: ReactElement }) {
92 return (
93 <Tooltip>
94 <TooltipTrigger render={children} />
95 <TooltipPopup>{label}</TooltipPopup>
96 </Tooltip>
97 );
98}
99
100function ThemeLibraryCard({
101 theme,
102 isActive,
103 onUse,
104 onUseMode,
105 activeModes,
106 onEdit,
107 onDuplicate,
108 onDownload,
109 onRemove,
110 variantNavigation,
111}: {
112 theme: ThemeCardDefinition;
113 isActive: boolean;
114 onUse: () => void;
115 onUseMode: (mode: ThemeMode) => void;
116 activeModes: ReadonlyArray<ThemeMode>;
117 onEdit?: () => void;
118 onDuplicate?: () => void;
119 onDownload?: () => void;
120 onRemove?: () => void;
121 variantNavigation?: {
122 collectionLabel: string;
123 options: ReadonlyArray<{
124 themeIndex: number;
125 label: string;
126 activeModes: ReadonlyArray<ThemeMode>;
127 preview: ThemeCardDefinition["previews"][number];
128 }>;
129 onSelectAndUse: (themeIndex: number, mode: ThemeAppearance) => void;
130 };
131}) {
132 // A one-appearance theme can only take its own side of the mix, so the card
133 // tooltip promises exactly what clicking it does.
134 const cardModes = theme.previews.map((preview) => preview.mode);
135 const [radialModeOpen, setRadialModeOpen] = useState<ThemeAppearance | null>(null);
136 const radialModeGroups = (["light", "dark"] as const).map((mode) => {
137 const options =
138 variantNavigation?.options.flatMap((option) => {
139 const preview = option.preview;
140 return preview.mode === mode ? [{ option, preview }] : [];
141 }) ?? [];
142 return {
143 mode,
144 options,
145 selected: options.find(({ option }) => option.activeModes.includes(mode)) ?? options[0],
146 };
147 });
148 return (
149 // The card surface stays a plain div (buttons cannot nest inside a button
150 // role); the title button and mode circles carry the accessible actions,
151 // while the card click is a pointer-only convenience.
152 <Tooltip>
153 <TooltipTrigger
154 render={
155 <div
156 className={cn(
157 "cursor-pointer overflow-hidden rounded-xl border border-border/70 bg-card/60 transition-colors hover:bg-accent/10",
158 isActive && "bg-accent/30",
159 )}
160 data-theme-library-card={theme.id}
161 onClick={onUse}
162 style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined}
163 >
164 <div className="relative">
165 {variantNavigation ? (
166 <div
167 aria-label="Light and dark theme variants"
168 className="relative h-20"
169 role="group"
170 onBlurCapture={(event) => {
171 const nextTarget = event.relatedTarget;
172 if (
173 !(nextTarget instanceof Node) ||
174 !event.currentTarget.contains(nextTarget)
175 ) {
176 setRadialModeOpen(null);
177 }
178 }}
179 onKeyDown={(event) => {
180 if (event.key === "Escape") setRadialModeOpen(null);
181 }}
182 onMouseLeave={() => setRadialModeOpen(null)}
183 >
184 {radialModeGroups.map(({ mode, options, selected }) => {
185 if (!selected) return null;
186 const rootOffsetX = mode === "light" ? -52 : 52;
187 const isOpen = radialModeOpen === mode;
188 const isActive = selected.option.activeModes.includes(mode);
189 const modeLabel = mode === "light" ? "Light" : "Dark";
190 return (
191 <div className="contents" key={mode}>
192 <ThemeVariantTooltip label={`${modeLabel}: ${selected.option.label}`}>
193 <button
194 aria-label={
195 options.length > 1
196 ? `Choose ${mode} variant, ${options.length} options, currently ${selected.option.label}`
197 : `Use ${mode} variant, currently ${selected.option.label}`
198 }
199 aria-pressed={isActive}
200 className="absolute left-1/2 top-2 z-20 flex size-14 items-center justify-center rounded-full outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring"
201 style={{
202 transform: `translateX(calc(-50% + ${rootOffsetX}px))`,
203 }}
204 type="button"
205 onClick={(event) => {
206 event.stopPropagation();
207 variantNavigation.onSelectAndUse(selected.option.themeIndex, mode);
208 }}
209 onFocus={() => setRadialModeOpen(mode)}
210 onMouseEnter={() => setRadialModeOpen(mode)}
211 >
212 <ThemePreviewCircle
213 colors={selected.preview.colors}
214 mode={selected.preview.mode}
215 />
216 {isActive ? (
217 <span
218 aria-hidden
219 className="pointer-events-none absolute inset-0 rounded-full ring-2 ring-inset ring-ring"
220 />
221 ) : null}
222 {isActive ? (
223 <span className="pointer-events-none absolute -bottom-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full border border-border/70 bg-background text-foreground shadow-sm">
224 {mode === "light" ? (
225 <SunIcon className="size-2.5" />
226 ) : (
227 <MoonIcon className="size-2.5" />
228 )}
229 </span>
230 ) : null}
231 </button>
232 </ThemeVariantTooltip>
233 <span
234 className="pointer-events-none absolute bottom-0 left-1/2 inline-flex max-w-24 -translate-x-1/2 items-center gap-1 text-2xs font-medium text-foreground"
235 style={{ marginLeft: rootOffsetX }}
236 >
237 <span className="truncate">{selected.option.label}</span>
238 {options.length > 1 ? (
239 <span className="shrink-0 rounded-full bg-muted px-1 text-3xs text-muted-foreground">
240 +{options.length - 1}
241 </span>
242 ) : null}
243 </span>
244 {options.length > 1
245 ? options.map(({ option, preview }, optionIndex) => {
246 const progress = optionIndex / (options.length - 1) - 0.5;
247 const childOffsetX = rootOffsetX + progress * 68;
248 const childOffsetY = Math.abs(progress) * 10;
249 const optionIsActive = option.activeModes.includes(mode);
250 return (
251 <ThemeVariantTooltip
252 key={option.label}
253 label={`Use ${option.label} for ${mode} mode`}
254 >
255 <button
256 aria-label={`Use ${option.label} for ${mode} mode${optionIsActive ? ", currently active" : ""}`}
257 aria-pressed={optionIsActive}
258 className={cn(
259 "absolute left-1/2 top-1 z-30 flex size-7 items-center justify-center rounded-full bg-background shadow-sm outline-none transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
260 optionIsActive
261 ? "ring-2 ring-inset ring-ring"
262 : "ring-1 ring-border/70",
263 )}
264 style={{
265 opacity: isOpen ? 1 : 0,
266 pointerEvents: isOpen ? "auto" : "none",
267 transform: `translate(calc(-50% + ${isOpen ? childOffsetX : rootOffsetX}px), ${isOpen ? childOffsetY : 28}px) scale(${isOpen ? 1 : 0.55})`,
268 transitionDelay: isOpen ? `${optionIndex * 35}ms` : "0ms",
269 }}
270 type="button"
271 onClick={(event) => {
272 event.stopPropagation();
273 variantNavigation.onSelectAndUse(option.themeIndex, mode);
274 }}
275 onFocus={() => setRadialModeOpen(mode)}
276 onMouseEnter={() => setRadialModeOpen(mode)}
277 >
278 <span className="pointer-events-none scale-[0.43]">
279 <ThemePreviewCircle
280 colors={preview.colors}
281 mode={preview.mode}
282 />
283 </span>
284 </button>
285 </ThemeVariantTooltip>
286 );
287 })
288 : null}
289 </div>
290 );
291 })}
292 </div>
293 ) : (
294 <ThemePreviewCircles
295 label={theme.label}
296 activeModes={activeModes}
297 onSelectMode={onUseMode}
298 previews={theme.previews}
299 />
300 )}
301 </div>
302 <div className="flex items-center gap-2 px-3 pb-3 pt-2">
303 <div className="min-w-0 flex-1">
304 <div className="flex items-center gap-1.5">
305 <button
306 aria-label={`Use ${variantNavigation ? `${variantNavigation.collectionLabel}, ${theme.label} variant` : `${theme.label} theme`}${isActive ? ", currently active" : ""}`}
307 aria-pressed={isActive}
308 className="min-w-0 cursor-pointer truncate rounded-sm text-left text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
309 type="button"
310 onClick={(event) => {
311 event.stopPropagation();
312 onUse();
313 }}
314 >
315 {variantNavigation?.collectionLabel ?? theme.label}
316 </button>
317 </div>
318 </div>
319 {onEdit || onDuplicate || onDownload || onRemove ? (
320 <div className="flex shrink-0 items-center gap-1">
321 {onDuplicate ? (
322 <Tooltip>
323 <TooltipTrigger
324 render={
325 <Button
326 aria-label={`Duplicate ${theme.label}`}
327 size="icon-xs"
328 variant="ghost"
329 onClick={(event) => {
330 event.stopPropagation();
331 onDuplicate();
332 }}
333 >
334 <CopyIcon />
335 </Button>
336 }
337 />
338 <TooltipPopup>Duplicate theme</TooltipPopup>
339 </Tooltip>
340 ) : null}
341 {onEdit ? (
342 <Tooltip>
343 <TooltipTrigger
344 render={
345 <Button
346 aria-label={`Edit ${theme.label}`}
347 size="icon-xs"
348 variant="ghost"
349 onClick={(event) => {
350 event.stopPropagation();
351 onEdit();
352 }}
353 >
354 <PenLineIcon />
355 </Button>
356 }
357 />
358 <TooltipPopup>Edit theme</TooltipPopup>
359 </Tooltip>
360 ) : null}
361 {onDownload ? (
362 <Tooltip>
363 <TooltipTrigger
364 render={
365 <Button
366 aria-label={`Export ${theme.label}`}
367 size="icon-xs"
368 variant="ghost"
369 onClick={(event) => {
370 event.stopPropagation();
371 onDownload();
372 }}
373 >
374 <UploadIcon />
375 </Button>
376 }
377 />
378 <TooltipPopup>Export theme file</TooltipPopup>
379 </Tooltip>
380 ) : null}
381 {onRemove ? (
382 <Tooltip>
383 <TooltipTrigger
384 render={
385 <Button
386 aria-label={
387 variantNavigation
388 ? `Remove themes from ${variantNavigation.collectionLabel}`
389 : `Remove ${theme.label}`
390 }
391 size="icon-xs"
392 variant="ghost-destructive"
393 onClick={(event) => {
394 event.stopPropagation();
395 onRemove();
396 }}
397 >
398 <Trash2Icon />
399 </Button>
400 }
401 />
402 <TooltipPopup>
403 {variantNavigation ? "Remove themes" : "Remove theme"}
404 </TooltipPopup>
405 </Tooltip>
406 ) : null}
407 </div>
408 ) : null}
409 </div>
410 </div>
411 }
412 />
413 <TooltipPopup>
414 {variantNavigation
415 ? "Use the first variants for light and dark"
416 : cardModes.length > 1
417 ? "Use for both light and dark"
418 : `Use for ${cardModes[0]} mode only`}
419 </TooltipPopup>
420 </Tooltip>
421 );
422}
423
424function CustomThemeCollectionCard({
425 themes,
426 activeModesFor,
427 onUse,
428 onUseMode,
429 onDuplicate,
430 onEdit,
431 onDownload,
432 onRemove,
433}: {
434 themes: ReadonlyArray<ThemeDefinition>;
435 activeModesFor: (themeId: string) => ReadonlyArray<ThemeMode>;
436 onUse: (theme: ThemeDefinition) => void;
437 onUseMode: (theme: ThemeDefinition, mode: ThemeMode) => void;
438 onDuplicate: (theme: ThemeDefinition) => void;
439 onEdit: (theme: ThemeDefinition) => void;
440 onDownload: (theme: ThemeDefinition) => void;
441 onRemove: (theme: ThemeDefinition) => void;
442}) {
443 const [variantIndex, setVariantIndex] = useState(() => {
444 const activeIndex = themes.findIndex((theme) => activeModesFor(theme.id).length > 0);
445 return activeIndex < 0 ? 0 : activeIndex;
446 });
447 const safeIndex = Math.min(variantIndex, themes.length - 1);
448 const theme = themes[safeIndex];
449
450 useEffect(() => {
451 if (variantIndex !== safeIndex) setVariantIndex(safeIndex);
452 }, [safeIndex, variantIndex]);
453
454 if (!theme) return null;
455 const collectionLabel = theme.collection?.label ?? theme.label;
456 const variantLabels = collectionVariantLabels(themes);
457 const defaultLightTheme = themes.find((candidate) => getThemeModes(candidate).includes("light"));
458 const defaultDarkTheme = themes.find((candidate) => getThemeModes(candidate).includes("dark"));
459 const selectCollectionDefaults = () => {
460 if (themes.length === 1) {
461 onUse(theme);
462 return;
463 }
464 if (defaultLightTheme) onUseMode(defaultLightTheme, "light");
465 if (defaultDarkTheme) onUseMode(defaultDarkTheme, "dark");
466 setVariantIndex(0);
467 };
468
469 return (
470 <ThemeLibraryCard
471 activeModes={activeModesFor(theme.id)}
472 isActive={false}
473 onDownload={() => onDownload(theme)}
474 onDuplicate={() => onDuplicate(theme)}
475 onEdit={() => onEdit(theme)}
476 onRemove={() => onRemove(theme)}
477 onUse={selectCollectionDefaults}
478 onUseMode={(mode) => onUseMode(theme, mode)}
479 theme={getThemeCardDefinition(theme)}
480 {...(themes.length > 1
481 ? {
482 variantNavigation: {
483 collectionLabel,
484 options: themes.flatMap((variant, themeIndex) =>
485 getThemeCardDefinition(variant).previews.map((preview) => ({
486 themeIndex,
487 label: variantLabels[themeIndex] ?? variant.label,
488 activeModes: activeModesFor(variant.id),
489 preview,
490 })),
491 ),
492 onSelectAndUse: (themeIndex, mode) => {
493 const selectedTheme = themes[themeIndex];
494 if (!selectedTheme) return;
495 setVariantIndex(themeIndex);
496 onUseMode(selectedTheme, mode);
497 },
498 },
499 }
500 : {})}
501 />
502 );
503}
504
505export function ThemeLibrary({
506 theme,
507 setTheme,
508 appearanceMode,
509 setAppearanceMode,
510 customThemes,
511 initialAppearance,
512 refreshTheme,
513 isImportOpen,
514 onImportOpenChange,
515 themeHalves,
516 setThemeHalf,
517}: {
518 theme: string;
519 setTheme: (theme: string) => boolean;
520 appearanceMode: ThemeMode;
521 setAppearanceMode: (mode: ThemeMode) => boolean;
522 customThemes: ReadonlyArray<ThemeDefinition>;
523 initialAppearance: ThemeAppearance;
524 refreshTheme: () => void;
525 isImportOpen: boolean;
526 onImportOpenChange: (open: boolean) => void;
527 themeHalves: ThemeHalves | null;
528 setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean;
529}) {
530 const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor);
531 const environmentThemes = useEnvironmentThemeDefinitions();
532 const [themeRemovalTarget, setThemeRemovalTarget] = useState<{
533 theme: ThemeDefinition;
534 collectionThemes: ReadonlyArray<ThemeDefinition>;
535 } | null>(null);
536 // Keep the target after closing so the dialog text remains populated during
537 // its exit animation. The next trash action replaces it before reopening.
538 const [isThemeRemovalOpen, setIsThemeRemovalOpen] = useState(false);
539 const [themeIdsToRemove, setThemeIdsToRemove] = useState<ReadonlyArray<string>>([]);
540 const themeIdsToRemoveSet = new Set(themeIdsToRemove);
541 const removeDialogTheme = themeRemovalTarget?.theme;
542 const removeDialogCollectionThemes = themeRemovalTarget?.collectionThemes ?? [];
543 const canRemoveCollection = removeDialogCollectionThemes.length > 1;
544 const removeDialogCollectionLabel =
545 removeDialogTheme?.collection?.label ?? removeDialogTheme?.label;
546
547 const notifyThemeSaveFailure = useCallback(() => {
548 toastManager.add(
549 stackedThreadToast({
550 type: "error",
551 title: "Couldn’t save theme selection",
552 description: "Try again.",
553 }),
554 );
555 }, []);
556
557 const notifyThemeRemovalFailure = useCallback(() => {
558 toastManager.add(
559 stackedThreadToast({
560 type: "error",
561 title: "Couldn’t remove theme",
562 description: "Try again.",
563 }),
564 );
565 }, []);
566
567 const persistTheme = useCallback(
568 (nextTheme: string) => {
569 const didSave = setTheme(nextTheme);
570 if (!didSave) notifyThemeSaveFailure();
571 return didSave;
572 },
573 [notifyThemeSaveFailure, setTheme],
574 );
575
576 const handleRemoveTheme = useCallback(
577 (customTheme: ThemeDefinition, collectionThemes: ReadonlyArray<ThemeDefinition>) => {
578 setThemeRemovalTarget({ theme: customTheme, collectionThemes });
579 setThemeIdsToRemove(collectionThemes.length > 1 ? [] : [customTheme.id]);
580 setIsThemeRemovalOpen(true);
581 },
582 [],
583 );
584
585 const handleConfirmRemoveTheme = useCallback(() => {
586 if (!themeRemovalTarget) return;
587 const removedIds = new Set(themeIdsToRemove);
588 if (removedIds.size === 0) return;
589 const removesBase = removedIds.has(getThemeDefinition(theme)?.id ?? "");
590 // Captured raw before persistTheme clears the mix: a half naming a
591 // published theme whose set has not streamed in yet is pruned from the
592 // `themeHalves` prop, and rebuilding from that would drop it.
593 const storedHalves = readThemeHalvesRaw();
594 // Keep the themes installed if we cannot move the selection off one of
595 // them; the dialog stays open so the user can retry or cancel.
596 if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) {
597 return;
598 }
599 for (const appearance of ["light", "dark"] as const) {
600 const half = storedHalves[appearance];
601 if (half === undefined) continue;
602 // Writing a base preference clears the whole mix, so halves that name
603 // a surviving theme are written back; removed halves fall back to base.
604 const next = half && removedIds.has(half) ? null : removesBase ? half : undefined;
605 if (next !== undefined && !setThemeHalf(appearance, next)) {
606 notifyThemeRemovalFailure();
607 return;
608 }
609 }
610 try {
611 removeCustomThemes([...removedIds]);
612 } catch {
613 notifyThemeRemovalFailure();
614 return;
615 }
616 setIsThemeRemovalOpen(false);
617 }, [
618 appearanceMode,
619 notifyThemeRemovalFailure,
620 persistTheme,
621 setThemeHalf,
622 theme,
623 themeIdsToRemove,
624 themeRemovalTarget,
625 ]);
626
627 // ----- Automatic-mode mixing -------------------------------------------
628 // The pair model: one theme owns light, one owns dark, and the global
629 // appearance mode (light / dark / auto) decides which is showing.
630 const baseCardId = getThemeDefinition(theme)?.id ?? null;
631 const lightOwner = themeHalves?.light ?? baseCardId;
632 const darkOwner = themeHalves?.dark ?? baseCardId;
633
634 const assignHalf = useCallback(
635 (appearance: ThemeAppearance, cardId: string | null) => {
636 const otherAppearance = appearance === "light" ? "dark" : "light";
637 // Picking the default over a themed base cannot be stored as a half:
638 // the base would still own that appearance. Convert the base into an
639 // explicit half on the other side so this side falls back to default.
640 if (cardId === null && baseCardId !== null) {
641 // Read raw, before persistTheme clears the mix: the other half may
642 // name a published theme that has not streamed in yet, and falling
643 // back to the base would silently rewrite it.
644 const otherOwner = readThemeHalvesRaw()[otherAppearance] ?? baseCardId;
645 if (!persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) return;
646 if (!setThemeHalf(otherAppearance, otherOwner)) {
647 // Best-effort rollback: restore the whole-theme selection rather
648 // than leaving the user with no theme at all.
649 setTheme(theme);
650 notifyThemeSaveFailure();
651 }
652 return;
653 }
654 if (!setThemeHalf(appearance, cardId)) {
655 notifyThemeSaveFailure();
656 }
657 },
658 [
659 appearanceMode,
660 baseCardId,
661 notifyThemeSaveFailure,
662 persistTheme,
663 setTheme,
664 setThemeHalf,
665 theme,
666 ],
667 );
668
669 // "Create theme" starts from whatever is on screen for the appearance being
670 // edited, so tuning the theme you already use never means rebuilding it.
671 const activeThemeForAppearance =
672 getThemeDefinition((initialAppearance === "light" ? lightOwner : darkOwner) ?? "") ?? null;
673
674 const cardDefById = (id: string | null): ThemeCardDefinition => {
675 if (id === null) return STANDARD_THEME_CARDS[0]!;
676 const definition = getThemeDefinition(id);
677 return definition ? getThemeCardDefinition(definition) : STANDARD_THEME_CARDS[0]!;
678 };
679
680 const pickColors = (id: string | null, appearance: ThemeAppearance) => {
681 const card = cardDefById(id);
682 return previewColorsOf(card, appearance) ?? card.previews[0]!.colors;
683 };
684
685 const setMode = (mode: ThemeMode) => {
686 if (!setAppearanceMode(mode)) notifyThemeSaveFailure();
687 };
688
689 // ----- Wireframe tiles on top, two-ball cards below --------------------
690 const handlePairPick = (cardId: string | null) => (mode: ThemeMode) => {
691 if (mode === "system") return;
692 assignHalf(mode, cardId);
693 };
694
695 // Rings always show the effective owner of each appearance: an unpicked
696 // half belongs to the default card (a null owner), so a fresh install
697 // shows T3 Code selected instead of nothing.
698 const pickedModesFor = (cardId: string | null): ThemeMode[] => {
699 const rings: ThemeMode[] = [];
700 if (lightOwner === cardId) rings.push("light");
701 if (darkOwner === cardId) rings.push("dark");
702 return rings;
703 };
704
705 const wireframeColors = (appearance: ThemeAppearance) =>
706 pickColors(appearance === "light" ? lightOwner : darkOwner, appearance);
707
708 const renderWireframe = (mode: ThemeMode) => (
709 <ThemeWireframe
710 className="h-[8.75rem]"
711 panes={
712 mode === "system"
713 ? [
714 { clip: "left", colors: wireframeColors("light") },
715 { clip: "right", colors: wireframeColors("dark") },
716 ]
717 : [{ colors: wireframeColors(mode === "dark" ? "dark" : "light") }]
718 }
719 />
720 );
721
722 const renderModeTiles = () => (
723 <div aria-label="Appearance mode" className="grid w-full grid-cols-3 gap-3" role="group">
724 {(["system", "light", "dark"] as const).map((mode) => {
725 const isActive = appearanceMode === mode;
726 return (
727 <button
728 aria-label={mode === "system" ? "Follow the system appearance" : `Use ${mode} mode`}
729 aria-pressed={isActive}
730 className={cn(
731 "flex cursor-pointer flex-col items-stretch gap-1.5 rounded-xl border p-2 outline-none transition-colors focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring",
732 isActive
733 ? "border-transparent bg-accent/30"
734 : "border-border/70 bg-card/60 hover:bg-accent/10",
735 )}
736 key={mode}
737 style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined}
738 onClick={() => setMode(mode)}
739 type="button"
740 >
741 {renderWireframe(mode)}
742 <span
743 className={cn(
744 "flex items-center justify-center text-xs font-medium",
745 isActive ? "text-foreground" : "text-muted-foreground",
746 )}
747 >
748 {mode === "system" ? "System" : mode === "light" ? "Light" : "Dark"}
749 </span>
750 </button>
751 );
752 })}
753 </div>
754 );
755
756 const customThemeCollections = [
757 ...customThemes
758 .reduce((groups, customTheme) => {
759 const groupId = customTheme.collection
760 ? `collection:${customTheme.collection.id}`
761 : `theme:${customTheme.id}`;
762 const group = groups.get(groupId);
763 if (group) group.push(customTheme);
764 else groups.set(groupId, [customTheme]);
765 return groups;
766 }, new Map<string, ThemeDefinition[]>())
767 .entries(),
768 ];
769
770 const renderPairGrid = () => (
771 // One shared provider so every tooltip in the grid hands off instantly to
772 // the next hovered trigger instead of stacking on top of it. The card
773 // tooltip briefly showing while crossing between a card's two circles is
774 // accepted — scoping the group tighter makes the handoffs feel sluggish.
775 <TooltipProvider>
776 <div
777 className="grid w-full gap-2"
778 style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 16rem), 1fr))" }}
779 >
780 {STANDARD_THEME_CARDS.map((standardTheme) => (
781 <ThemeLibraryCard
782 activeModes={pickedModesFor(null)}
783 isActive={false}
784 key={standardTheme.id}
785 onDuplicate={() =>
786 openThemeEditor({
787 editingThemeId: null,
788 seedThemeId: null,
789 seedName: `${standardTheme.label} copy`,
790 initialAppearance,
791 })
792 }
793 onUse={() => persistTheme(appearanceMode === "system" ? "system" : appearanceMode)}
794 onUseMode={handlePairPick(null)}
795 theme={standardTheme}
796 />
797 ))}
798 {MAINTAINER_THEMES.map((maintainerTheme) => {
799 const card = getThemeCardDefinition(maintainerTheme);
800 return (
801 <ThemeLibraryCard
802 activeModes={pickedModesFor(maintainerTheme.id)}
803 isActive={false}
804 key={maintainerTheme.id}
805 onDuplicate={() =>
806 openThemeEditor({
807 editingThemeId: null,
808 seedThemeId: maintainerTheme.id,
809 seedName: `${maintainerTheme.label} copy`,
810 initialAppearance,
811 })
812 }
813 onUse={() => persistTheme(maintainerTheme.id)}
814 onUseMode={handlePairPick(maintainerTheme.id)}
815 theme={card}
816 />
817 );
818 })}
819 {environmentThemes
820 .filter(
821 // A saved theme with the same id wins resolution, so its card is
822 // the one that must show; rendering both would also collide keys.
823 (environmentTheme) => !customThemes.some((theme) => theme.id === environmentTheme.id),
824 )
825 .map((environmentTheme) => (
826 // No edit or remove: the environment republishes these palettes on
827 // every change, so anything saved here would be overwritten.
828 // Duplicating is the way to keep a copy.
829 <ThemeLibraryCard
830 activeModes={pickedModesFor(environmentTheme.id)}
831 isActive={false}
832 key={environmentTheme.id}
833 onDuplicate={() =>
834 openThemeEditor({
835 editingThemeId: null,
836 seedThemeId: environmentTheme.id,
837 seedName: `${environmentTheme.label} copy`,
838 initialAppearance,
839 })
840 }
841 onUse={() => {
842 const half = singleAppearanceOf(environmentTheme);
843 if (half === null) persistTheme(environmentTheme.id);
844 else assignHalf(half, environmentTheme.id);
845 }}
846 onUseMode={handlePairPick(environmentTheme.id)}
847 theme={getThemeCardDefinition(environmentTheme)}
848 />
849 ))}
850 {customThemeCollections.map(([collectionId, themes]) => (
851 <CustomThemeCollectionCard
852 activeModesFor={pickedModesFor}
853 key={collectionId}
854 onDownload={(customTheme) =>
855 downloadThemeFile(`${customTheme.id}.json`, serializeThemeFile(customTheme))
856 }
857 onDuplicate={(customTheme) =>
858 openThemeEditor({
859 editingThemeId: null,
860 seedThemeId: customTheme.id,
861 seedName: `${customTheme.label} copy`,
862 initialAppearance,
863 })
864 }
865 onEdit={(customTheme) =>
866 openThemeEditor({
867 editingThemeId: customTheme.id,
868 seedThemeId: null,
869 seedName: null,
870 initialAppearance,
871 })
872 }
873 onRemove={(customTheme) => handleRemoveTheme(customTheme, themes)}
874 onUse={(customTheme) => {
875 const modes = getThemeModes(customTheme);
876 if (modes.length === 1) assignHalf(modes[0]!, customTheme.id);
877 else persistTheme(customTheme.id);
878 }}
879 onUseMode={(customTheme, mode) => handlePairPick(customTheme.id)(mode)}
880 themes={themes}
881 />
882 ))}
883 </div>
884 </TooltipProvider>
885 );
886
887 return (
888 <div className="space-y-3">
889 <h3 className="px-3 text-sm font-normal text-foreground/70 sm:px-4">
890 {searchableSetting("color-scheme").title}
891 </h3>
892 {renderModeTiles()}
893 <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4">
894 <h3 className="text-sm font-normal text-foreground/70">
895 {searchableSetting("theme").title}
896 </h3>
897 <div className="flex flex-wrap items-center justify-end gap-2">
898 <Button
899 size="xs"
900 variant="outline"
901 onClick={() =>
902 openThemeEditor({
903 editingThemeId: null,
904 seedThemeId: activeThemeForAppearance?.id ?? null,
905 seedName: null,
906 initialAppearance,
907 })
908 }
909 >
910 <PaintbrushIcon />
911 Create theme
912 </Button>
913 <Button size="xs" variant="outline" onClick={() => onImportOpenChange(true)}>
914 <PlusIcon />
915 Add theme
916 </Button>
917 </div>
918 </div>
919 {renderPairGrid()}
920 <ThemeImportDialog
921 onImportedMany={(importedThemes, { updated }) => {
922 // Re-apply after collection updates. The update may remove the
923 // selected variant, in which case the theme hook falls back safely.
924 if (updated) refreshTheme();
925 const verb = updated ? "updated" : "added";
926 toastManager.add(
927 stackedThreadToast({
928 type: "success",
929 title:
930 importedThemes.length === 1
931 ? `${importedThemes[0]!.label} ${verb}`
932 : `${importedThemes.length} themes ${verb}`,
933 description: importedThemes.map((imported) => imported.label).join(", "),
934 }),
935 );
936 }}
937 onImported={(importedTheme) => {
938 // Same rule as clicking the card: a one-appearance theme takes its
939 // side of the mix instead of becoming the base for both.
940 const modes = getThemeModes(importedTheme);
941 if (modes.length === 1) {
942 assignHalf(modes[0]!, importedTheme.id);
943 toastManager.add(
944 stackedThreadToast({
945 type: "success",
946 title: `${importedTheme.label} added`,
947 description: `It’s now your ${modes[0]!} theme.`,
948 }),
949 );
950 return true;
951 }
952 if (!persistTheme(importedTheme.id)) return false;
953 toastManager.add(
954 stackedThreadToast({
955 type: "success",
956 title: `${importedTheme.label} added`,
957 description: "It’s now active.",
958 }),
959 );
960 return true;
961 }}
962 onOpenChange={onImportOpenChange}
963 open={isImportOpen}
964 />
965 <AlertDialog open={isThemeRemovalOpen} onOpenChange={setIsThemeRemovalOpen}>
966 <AlertDialogPopup>
967 <AlertDialogHeader>
968 <AlertDialogTitle>
969 {canRemoveCollection
970 ? `Remove themes from “${removeDialogCollectionLabel}”?`
971 : `Remove “${removeDialogTheme?.label}”?`}
972 </AlertDialogTitle>
973 <AlertDialogDescription>
974 {canRemoveCollection
975 ? "Select the variants you want to remove. You can restore them by importing the extension again."
976 : "You can bring it back anytime by importing its JSON file."}
977 </AlertDialogDescription>
978 </AlertDialogHeader>
979 {canRemoveCollection ? (
980 <div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto px-6 pb-6 sm:grid-cols-2">
981 {removeDialogCollectionThemes.map((customTheme) => {
982 const checked = themeIdsToRemoveSet.has(customTheme.id);
983 const card = getThemeCardDefinition(customTheme);
984 const checkboxId = `remove-theme-${customTheme.id}`;
985 return (
986 <label
987 className="group relative flex min-h-28 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-border/70 bg-muted/25 p-3 has-checked:border-ring has-checked:bg-accent/20 hover:bg-muted/40"
988 htmlFor={checkboxId}
989 key={customTheme.id}
990 >
991 <span className="absolute right-2 top-2 inline-grid size-5 grid-cols-1 sm:size-4">
992 <input
993 checked={checked}
994 className="col-start-1 row-start-1 size-full appearance-none rounded-sm border border-input bg-background outline-none checked:border-primary checked:bg-primary focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring checked:focus-visible:inset-ring-3 checked:focus-visible:inset-ring-background dark:not-checked:bg-input/32 forced-colors:appearance-auto"
995 id={checkboxId}
996 name="themes-to-remove"
997 type="checkbox"
998 onChange={(event) => {
999 const shouldRemove = event.currentTarget.checked;
1000 setThemeIdsToRemove((current) =>
1001 shouldRemove
1002 ? [...current, customTheme.id]
1003 : current.filter((themeId) => themeId !== customTheme.id),
1004 );
1005 }}
1006 />
1007 <CheckIcon className="pointer-events-none col-start-1 row-start-1 size-3.5 shrink-0 self-center justify-self-center stroke-primary-foreground opacity-0 group-has-checked:opacity-100 sm:size-3" />
1008 </span>
1009 <span className="flex min-h-12 items-center justify-center gap-1">
1010 {card.previews.map((preview) => (
1011 <span
1012 className="flex size-11 shrink-0 items-center justify-center"
1013 key={preview.mode}
1014 >
1015 <span className="flex scale-75">
1016 <ThemePreviewCircle colors={preview.colors} mode={preview.mode} />
1017 </span>
1018 </span>
1019 ))}
1020 </span>
1021 <p className="max-w-full truncate text-center text-base font-medium text-foreground sm:text-sm">
1022 {customTheme.label}
1023 </p>
1024 </label>
1025 );
1026 })}
1027 </div>
1028 ) : null}
1029 <AlertDialogFooter>
1030 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
1031 <Button
1032 disabled={themeIdsToRemove.length === 0}
1033 variant="destructive"
1034 onClick={handleConfirmRemoveTheme}
1035 >
1036 {canRemoveCollection
1037 ? `Remove selected${themeIdsToRemove.length > 0 ? ` (${themeIdsToRemove.length})` : ""}`
1038 : "Remove theme"}
1039 </Button>
1040 </AlertDialogFooter>
1041 </AlertDialogPopup>
1042 </AlertDialog>
1043 </div>
1044 );
1045}