Open History to see the last commit
1import {
2 CheckIcon,
3 CopyIcon,
4 MoonIcon,
5 PaintbrushIcon,
6 PenLineIcon,
7 PlusIcon,
8 SunIcon,
9 Trash2Icon,
10 UploadIcon,
11} from "lucide-react";
12import { 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 // Delay revocation so browsers can open the download stream.
87 setTimeout(() => URL.revokeObjectURL(url), 30_000);
88}
89
90function ThemeVariantTooltip({ label, children }: { label: string; children: ReactElement }) {
91 return (
92 <Tooltip>
93 <TooltipTrigger render={children} />
94 <TooltipPopup>{label}</TooltipPopup>
95 </Tooltip>
96 );
97}
98
99function ThemeLibraryCard({
100 theme,
101 isActive,
102 onUse,
103 onUseMode,
104 activeModes,
105 onEdit,
106 onDuplicate,
107 onDownload,
108 onRemove,
109 variantNavigation,
110}: {
111 theme: ThemeCardDefinition;
112 isActive: boolean;
113 onUse: () => void;
114 onUseMode: (mode: ThemeMode) => void;
115 activeModes: ReadonlyArray<ThemeMode>;
116 onEdit?: () => void;
117 onDuplicate?: () => void;
118 onDownload?: () => void;
119 onRemove?: () => void;
120 variantNavigation?: {
121 collectionLabel: string;
122 options: ReadonlyArray<{
123 themeIndex: number;
124 label: string;
125 activeModes: ReadonlyArray<ThemeMode>;
126 preview: ThemeCardDefinition["previews"][number];
127 }>;
128 onSelectAndUse: (themeIndex: number, mode: ThemeAppearance) => void;
129 };
130}) {
131 const cardModes = theme.previews.map((preview) => preview.mode);
132 const [radialModeOpen, setRadialModeOpen] = useState<ThemeAppearance | null>(null);
133 const radialModeGroups = (["light", "dark"] as const).map((mode) => {
134 const options =
135 variantNavigation?.options.flatMap((option) => {
136 const preview = option.preview;
137 return preview.mode === mode ? [{ option, preview }] : [];
138 }) ?? [];
139 return {
140 mode,
141 options,
142 selected: options.find(({ option }) => option.activeModes.includes(mode)) ?? options[0],
143 };
144 });
145 return (
146 <Tooltip>
147 <TooltipTrigger
148 render={
149 <div
150 className={cn(
151 "cursor-pointer overflow-hidden rounded-xl border border-border/70 bg-card/60 transition-colors hover:bg-accent/10",
152 isActive && "bg-accent/30",
153 )}
154 data-theme-library-card={theme.id}
155 onClick={onUse}
156 style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined}
157 >
158 <div className="relative">
159 {variantNavigation ? (
160 <div
161 aria-label="Light and dark theme variants"
162 className="relative h-20"
163 role="group"
164 onBlurCapture={(event) => {
165 const nextTarget = event.relatedTarget;
166 if (
167 !(nextTarget instanceof Node) ||
168 !event.currentTarget.contains(nextTarget)
169 ) {
170 setRadialModeOpen(null);
171 }
172 }}
173 onKeyDown={(event) => {
174 if (event.key === "Escape") setRadialModeOpen(null);
175 }}
176 onMouseLeave={() => setRadialModeOpen(null)}
177 >
178 {radialModeGroups.map(({ mode, options, selected }) => {
179 if (!selected) return null;
180 const rootOffsetX = mode === "light" ? -52 : 52;
181 const isOpen = radialModeOpen === mode;
182 const isActive = selected.option.activeModes.includes(mode);
183 const modeLabel = mode === "light" ? "Light" : "Dark";
184 return (
185 <div className="contents" key={mode}>
186 <ThemeVariantTooltip label={`${modeLabel}: ${selected.option.label}`}>
187 <button
188 aria-label={
189 options.length > 1
190 ? `Choose ${mode} variant, ${options.length} options, currently ${selected.option.label}`
191 : `Use ${mode} variant, currently ${selected.option.label}`
192 }
193 aria-pressed={isActive}
194 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-5 focus-visible:outline-foreground/60"
195 style={{
196 transform: `translateX(calc(-50% + ${rootOffsetX}px))`,
197 }}
198 type="button"
199 onClick={(event) => {
200 event.stopPropagation();
201 variantNavigation.onSelectAndUse(selected.option.themeIndex, mode);
202 }}
203 onFocus={() => setRadialModeOpen(mode)}
204 onMouseEnter={() => setRadialModeOpen(mode)}
205 >
206 <ThemePreviewCircle
207 colors={selected.preview.colors}
208 mode={selected.preview.mode}
209 />
210 {isActive ? (
211 <span
212 aria-hidden
213 className="pointer-events-none absolute inset-0 rounded-full ring-2 ring-inset ring-ring"
214 />
215 ) : null}
216 {isActive ? (
217 <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">
218 {mode === "light" ? (
219 <SunIcon className="size-2.5" />
220 ) : (
221 <MoonIcon className="size-2.5" />
222 )}
223 </span>
224 ) : null}
225 </button>
226 </ThemeVariantTooltip>
227 <span
228 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"
229 style={{ marginLeft: rootOffsetX }}
230 >
231 <span className="truncate">{selected.option.label}</span>
232 {options.length > 1 ? (
233 <span className="shrink-0 rounded-full bg-muted px-1 text-3xs text-muted-foreground">
234 +{options.length - 1}
235 </span>
236 ) : null}
237 </span>
238 {options.length > 1
239 ? options.map(({ option, preview }, optionIndex) => {
240 const progress = optionIndex / (options.length - 1) - 0.5;
241 const childOffsetX = rootOffsetX + progress * 68;
242 const childOffsetY = Math.abs(progress) * 10;
243 const optionIsActive = option.activeModes.includes(mode);
244 return (
245 <ThemeVariantTooltip
246 key={option.label}
247 label={`Use ${option.label} for ${mode} mode`}
248 >
249 <button
250 aria-label={`Use ${option.label} for ${mode} mode${optionIsActive ? ", currently active" : ""}`}
251 aria-pressed={optionIsActive}
252 className={cn(
253 "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",
254 optionIsActive
255 ? "ring-2 ring-inset ring-ring"
256 : "ring-1 ring-border/70",
257 )}
258 style={{
259 opacity: isOpen ? 1 : 0,
260 pointerEvents: isOpen ? "auto" : "none",
261 transform: `translate(calc(-50% + ${isOpen ? childOffsetX : rootOffsetX}px), ${isOpen ? childOffsetY : 28}px) scale(${isOpen ? 1 : 0.55})`,
262 transitionDelay: isOpen ? `${optionIndex * 35}ms` : "0ms",
263 }}
264 type="button"
265 onClick={(event) => {
266 event.stopPropagation();
267 variantNavigation.onSelectAndUse(option.themeIndex, mode);
268 }}
269 onFocus={() => setRadialModeOpen(mode)}
270 onMouseEnter={() => setRadialModeOpen(mode)}
271 >
272 <span className="pointer-events-none scale-[0.43]">
273 <ThemePreviewCircle
274 colors={preview.colors}
275 mode={preview.mode}
276 />
277 </span>
278 </button>
279 </ThemeVariantTooltip>
280 );
281 })
282 : null}
283 </div>
284 );
285 })}
286 </div>
287 ) : (
288 <ThemePreviewCircles
289 label={theme.label}
290 activeModes={activeModes}
291 onSelectMode={onUseMode}
292 previews={theme.previews}
293 />
294 )}
295 </div>
296 <div className="flex items-center gap-2 px-3 pb-3 pt-2">
297 <div className="min-w-0 flex-1">
298 <div className="flex items-center gap-1.5">
299 <button
300 aria-label={`Use ${variantNavigation ? `${variantNavigation.collectionLabel}, ${theme.label} variant` : `${theme.label} theme`}${isActive ? ", currently active" : ""}`}
301 aria-pressed={isActive}
302 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"
303 type="button"
304 onClick={(event) => {
305 event.stopPropagation();
306 onUse();
307 }}
308 >
309 {variantNavigation?.collectionLabel ?? theme.label}
310 </button>
311 </div>
312 </div>
313 {onEdit || onDuplicate || onDownload || onRemove ? (
314 <div className="flex shrink-0 items-center gap-1">
315 {onDuplicate ? (
316 <Tooltip>
317 <TooltipTrigger
318 render={
319 <Button
320 aria-label={`Duplicate ${theme.label}`}
321 size="icon-xs"
322 variant="ghost"
323 onClick={(event) => {
324 event.stopPropagation();
325 onDuplicate();
326 }}
327 >
328 <CopyIcon />
329 </Button>
330 }
331 />
332 <TooltipPopup>Duplicate theme</TooltipPopup>
333 </Tooltip>
334 ) : null}
335 {onEdit ? (
336 <Tooltip>
337 <TooltipTrigger
338 render={
339 <Button
340 aria-label={`Edit ${theme.label}`}
341 size="icon-xs"
342 variant="ghost"
343 onClick={(event) => {
344 event.stopPropagation();
345 onEdit();
346 }}
347 >
348 <PenLineIcon />
349 </Button>
350 }
351 />
352 <TooltipPopup>Edit theme</TooltipPopup>
353 </Tooltip>
354 ) : null}
355 {onDownload ? (
356 <Tooltip>
357 <TooltipTrigger
358 render={
359 <Button
360 aria-label={`Export ${theme.label}`}
361 size="icon-xs"
362 variant="ghost"
363 onClick={(event) => {
364 event.stopPropagation();
365 onDownload();
366 }}
367 >
368 <UploadIcon />
369 </Button>
370 }
371 />
372 <TooltipPopup>Export theme file</TooltipPopup>
373 </Tooltip>
374 ) : null}
375 {onRemove ? (
376 <Tooltip>
377 <TooltipTrigger
378 render={
379 <Button
380 aria-label={
381 variantNavigation
382 ? `Remove themes from ${variantNavigation.collectionLabel}`
383 : `Remove ${theme.label}`
384 }
385 size="icon-xs"
386 variant="ghost-destructive"
387 onClick={(event) => {
388 event.stopPropagation();
389 onRemove();
390 }}
391 >
392 <Trash2Icon />
393 </Button>
394 }
395 />
396 <TooltipPopup>
397 {variantNavigation ? "Remove themes" : "Remove theme"}
398 </TooltipPopup>
399 </Tooltip>
400 ) : null}
401 </div>
402 ) : null}
403 </div>
404 </div>
405 }
406 />
407 <TooltipPopup>
408 {variantNavigation
409 ? "Use the first variants for light and dark"
410 : cardModes.length > 1
411 ? "Use for both light and dark"
412 : `Use for ${cardModes[0]} mode only`}
413 </TooltipPopup>
414 </Tooltip>
415 );
416}
417
418function CustomThemeCollectionCard({
419 themes,
420 activeModesFor,
421 onUse,
422 onUseMode,
423 onDuplicate,
424 onEdit,
425 onDownload,
426 onRemove,
427}: {
428 themes: ReadonlyArray<ThemeDefinition>;
429 activeModesFor: (themeId: string) => ReadonlyArray<ThemeMode>;
430 onUse: (theme: ThemeDefinition) => void;
431 onUseMode: (theme: ThemeDefinition, mode: ThemeMode) => void;
432 onDuplicate: (theme: ThemeDefinition) => void;
433 onEdit: (theme: ThemeDefinition) => void;
434 onDownload: (theme: ThemeDefinition) => void;
435 onRemove: (theme: ThemeDefinition) => void;
436}) {
437 const [variantIndex, setVariantIndex] = useState(() => {
438 const activeIndex = themes.findIndex((theme) => activeModesFor(theme.id).length > 0);
439 return activeIndex < 0 ? 0 : activeIndex;
440 });
441 const safeIndex = Math.min(variantIndex, themes.length - 1);
442 const theme = themes[safeIndex];
443
444 if (variantIndex !== safeIndex) setVariantIndex(safeIndex);
445
446 if (!theme) return null;
447 const collectionLabel = theme.collection?.label ?? theme.label;
448 const variantLabels = collectionVariantLabels(themes);
449 const defaultLightTheme = themes.find((candidate) => getThemeModes(candidate).includes("light"));
450 const defaultDarkTheme = themes.find((candidate) => getThemeModes(candidate).includes("dark"));
451 const selectCollectionDefaults = () => {
452 if (themes.length === 1) {
453 onUse(theme);
454 return;
455 }
456 if (defaultLightTheme) onUseMode(defaultLightTheme, "light");
457 if (defaultDarkTheme) onUseMode(defaultDarkTheme, "dark");
458 setVariantIndex(0);
459 };
460
461 return (
462 <ThemeLibraryCard
463 activeModes={activeModesFor(theme.id)}
464 isActive={false}
465 onDownload={() => onDownload(theme)}
466 onDuplicate={() => onDuplicate(theme)}
467 onEdit={() => onEdit(theme)}
468 onRemove={() => onRemove(theme)}
469 onUse={selectCollectionDefaults}
470 onUseMode={(mode) => onUseMode(theme, mode)}
471 theme={getThemeCardDefinition(theme)}
472 {...(themes.length > 1
473 ? {
474 variantNavigation: {
475 collectionLabel,
476 options: themes.flatMap((variant, themeIndex) =>
477 getThemeCardDefinition(variant).previews.map((preview) => ({
478 themeIndex,
479 label: variantLabels[themeIndex] ?? variant.label,
480 activeModes: activeModesFor(variant.id),
481 preview,
482 })),
483 ),
484 onSelectAndUse: (themeIndex, mode) => {
485 const selectedTheme = themes[themeIndex];
486 if (!selectedTheme) return;
487 setVariantIndex(themeIndex);
488 onUseMode(selectedTheme, mode);
489 },
490 },
491 }
492 : {})}
493 />
494 );
495}
496
497export function ThemeLibrary({
498 theme,
499 setTheme,
500 appearanceMode,
501 setAppearanceMode,
502 customThemes,
503 initialAppearance,
504 refreshTheme,
505 isImportOpen,
506 onImportOpenChange,
507 themeHalves,
508 setThemeHalf,
509}: {
510 theme: string;
511 setTheme: (theme: string) => boolean;
512 appearanceMode: ThemeMode;
513 setAppearanceMode: (mode: ThemeMode) => boolean;
514 customThemes: ReadonlyArray<ThemeDefinition>;
515 initialAppearance: ThemeAppearance;
516 refreshTheme: () => void;
517 isImportOpen: boolean;
518 onImportOpenChange: (open: boolean) => void;
519 themeHalves: ThemeHalves | null;
520 setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean;
521}) {
522 const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor);
523 const environmentThemes = useEnvironmentThemeDefinitions();
524 const [themeRemovalTarget, setThemeRemovalTarget] = useState<{
525 theme: ThemeDefinition;
526 collectionThemes: ReadonlyArray<ThemeDefinition>;
527 } | null>(null);
528 const [isThemeRemovalOpen, setIsThemeRemovalOpen] = useState(false);
529 const [themeIdsToRemove, setThemeIdsToRemove] = useState<ReadonlyArray<string>>([]);
530 const themeIdsToRemoveSet = new Set(themeIdsToRemove);
531 const removeDialogTheme = themeRemovalTarget?.theme;
532 const removeDialogCollectionThemes = themeRemovalTarget?.collectionThemes ?? [];
533 const canRemoveCollection = removeDialogCollectionThemes.length > 1;
534 const removeDialogCollectionLabel =
535 removeDialogTheme?.collection?.label ?? removeDialogTheme?.label;
536
537 const notifyThemeSaveFailure = () => {
538 toastManager.add(
539 stackedThreadToast({
540 type: "error",
541 title: "Couldn’t save theme selection",
542 description: "Try again.",
543 }),
544 );
545 };
546
547 const notifyThemeRemovalFailure = () => {
548 toastManager.add(
549 stackedThreadToast({
550 type: "error",
551 title: "Couldn’t remove theme",
552 description: "Try again.",
553 }),
554 );
555 };
556
557 const persistTheme = (nextTheme: string) => {
558 const didSave = setTheme(nextTheme);
559 if (!didSave) notifyThemeSaveFailure();
560 return didSave;
561 };
562
563 const handleRemoveTheme = (
564 customTheme: ThemeDefinition,
565 collectionThemes: ReadonlyArray<ThemeDefinition>,
566 ) => {
567 setThemeRemovalTarget({ theme: customTheme, collectionThemes });
568 setThemeIdsToRemove(collectionThemes.length > 1 ? [] : [customTheme.id]);
569 setIsThemeRemovalOpen(true);
570 };
571
572 const handleConfirmRemoveTheme = () => {
573 if (!themeRemovalTarget) return;
574 const removedIds = new Set(themeIdsToRemove);
575 if (removedIds.size === 0) return;
576 const removesBase = removedIds.has(getThemeDefinition(theme)?.id ?? "");
577 // Read raw halves before persistTheme clears them; themeHalves omits published themes not yet streamed
578 // in.
579 const storedHalves = readThemeHalvesRaw();
580 if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) {
581 return;
582 }
583 for (const appearance of ["light", "dark"] as const) {
584 const half = storedHalves[appearance];
585 if (half === undefined) continue;
586 const next = half && removedIds.has(half) ? null : removesBase ? half : undefined;
587 if (next !== undefined && !setThemeHalf(appearance, next)) {
588 notifyThemeRemovalFailure();
589 return;
590 }
591 }
592 try {
593 removeCustomThemes([...removedIds]);
594 } catch {
595 notifyThemeRemovalFailure();
596 return;
597 }
598 setIsThemeRemovalOpen(false);
599 };
600
601 const baseCardId = getThemeDefinition(theme)?.id ?? null;
602 const lightOwner = themeHalves?.light ?? baseCardId;
603 const darkOwner = themeHalves?.dark ?? baseCardId;
604
605 const assignHalf = (appearance: ThemeAppearance, cardId: string | null) => {
606 const otherAppearance = appearance === "light" ? "dark" : "light";
607 // To restore one default appearance, move the themed base to the opposite explicit half.
608 if (cardId === null && baseCardId !== null) {
609 // Read raw before persistTheme clears the mix; unstreamed published halves must not fall back to the
610 // base.
611 const otherOwner = readThemeHalvesRaw()[otherAppearance] ?? baseCardId;
612 if (!persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) return;
613 if (!setThemeHalf(otherAppearance, otherOwner)) {
614 setTheme(theme);
615 notifyThemeSaveFailure();
616 }
617 return;
618 }
619 if (!setThemeHalf(appearance, cardId)) {
620 notifyThemeSaveFailure();
621 }
622 };
623
624 const activeThemeForAppearance =
625 getThemeDefinition((initialAppearance === "light" ? lightOwner : darkOwner) ?? "") ?? null;
626
627 const cardDefById = (id: string | null): ThemeCardDefinition => {
628 if (id === null) return STANDARD_THEME_CARDS[0]!;
629 const definition = getThemeDefinition(id);
630 return definition ? getThemeCardDefinition(definition) : STANDARD_THEME_CARDS[0]!;
631 };
632
633 const pickColors = (id: string | null, appearance: ThemeAppearance) => {
634 const card = cardDefById(id);
635 return previewColorsOf(card, appearance) ?? card.previews[0]!.colors;
636 };
637
638 const setMode = (mode: ThemeMode) => {
639 if (!setAppearanceMode(mode)) notifyThemeSaveFailure();
640 };
641
642 const handlePairPick = (cardId: string | null) => (mode: ThemeMode) => {
643 if (mode === "system") return;
644 assignHalf(mode, cardId);
645 };
646
647 const pickedModesFor = (cardId: string | null): ThemeMode[] => {
648 const rings: ThemeMode[] = [];
649 if (lightOwner === cardId) rings.push("light");
650 if (darkOwner === cardId) rings.push("dark");
651 return rings;
652 };
653
654 const wireframeColors = (appearance: ThemeAppearance) =>
655 pickColors(appearance === "light" ? lightOwner : darkOwner, appearance);
656
657 const renderWireframe = (mode: ThemeMode) => (
658 <ThemeWireframe
659 className="h-[8.75rem]"
660 panes={
661 mode === "system"
662 ? [
663 { clip: "left", colors: wireframeColors("light") },
664 { clip: "right", colors: wireframeColors("dark") },
665 ]
666 : [{ colors: wireframeColors(mode === "dark" ? "dark" : "light") }]
667 }
668 />
669 );
670
671 const renderModeTiles = () => (
672 <div aria-label="Appearance mode" className="grid w-full grid-cols-3 gap-3" role="group">
673 {(["system", "light", "dark"] as const).map((mode) => {
674 const isActive = appearanceMode === mode;
675 return (
676 <button
677 aria-label={mode === "system" ? "Follow the system appearance" : `Use ${mode} mode`}
678 aria-pressed={isActive}
679 className={cn(
680 // The accent ring marks the picked tile; focus is a neutral ring set inside it with a gap.
681 "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-7 focus-visible:outline-foreground/60",
682 isActive
683 ? "border-transparent bg-accent/30 ring-2 ring-inset ring-ring"
684 : "border-border/70 bg-card/60 hover:bg-accent/10",
685 )}
686 key={mode}
687 onClick={() => setMode(mode)}
688 type="button"
689 >
690 {renderWireframe(mode)}
691 <span
692 className={cn(
693 "flex items-center justify-center text-xs font-medium",
694 isActive ? "text-foreground" : "text-muted-foreground",
695 )}
696 >
697 {mode === "system" ? "System" : mode === "light" ? "Light" : "Dark"}
698 </span>
699 </button>
700 );
701 })}
702 </div>
703 );
704
705 const customThemeCollections = [
706 ...customThemes
707 .reduce((groups, customTheme) => {
708 const groupId = customTheme.collection
709 ? `collection:${customTheme.collection.id}`
710 : `theme:${customTheme.id}`;
711 const group = groups.get(groupId);
712 if (group) group.push(customTheme);
713 else groups.set(groupId, [customTheme]);
714 return groups;
715 }, new Map<string, ThemeDefinition[]>())
716 .entries(),
717 ];
718
719 const renderPairGrid = () => (
720 <TooltipProvider>
721 <div
722 className="grid w-full gap-2"
723 style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 16rem), 1fr))" }}
724 >
725 {STANDARD_THEME_CARDS.map((standardTheme) => (
726 <ThemeLibraryCard
727 activeModes={pickedModesFor(null)}
728 isActive={false}
729 key={standardTheme.id}
730 onDuplicate={() =>
731 openThemeEditor({
732 editingThemeId: null,
733 seedThemeId: null,
734 seedName: `${standardTheme.label} copy`,
735 initialAppearance,
736 })
737 }
738 onUse={() => persistTheme(appearanceMode === "system" ? "system" : appearanceMode)}
739 onUseMode={handlePairPick(null)}
740 theme={standardTheme}
741 />
742 ))}
743 {MAINTAINER_THEMES.map((maintainerTheme) => {
744 const card = getThemeCardDefinition(maintainerTheme);
745 return (
746 <ThemeLibraryCard
747 activeModes={pickedModesFor(maintainerTheme.id)}
748 isActive={false}
749 key={maintainerTheme.id}
750 onDuplicate={() =>
751 openThemeEditor({
752 editingThemeId: null,
753 seedThemeId: maintainerTheme.id,
754 seedName: `${maintainerTheme.label} copy`,
755 initialAppearance,
756 })
757 }
758 onUse={() => persistTheme(maintainerTheme.id)}
759 onUseMode={handlePairPick(maintainerTheme.id)}
760 theme={card}
761 />
762 );
763 })}
764 {environmentThemes
765 .filter(
766 // Saved themes win id resolution; show only their card to avoid duplicate keys.
767 (environmentTheme) => !customThemes.some((theme) => theme.id === environmentTheme.id),
768 )
769 .map((environmentTheme) => (
770 <ThemeLibraryCard
771 activeModes={pickedModesFor(environmentTheme.id)}
772 isActive={false}
773 key={environmentTheme.id}
774 onDuplicate={() =>
775 openThemeEditor({
776 editingThemeId: null,
777 seedThemeId: environmentTheme.id,
778 seedName: `${environmentTheme.label} copy`,
779 initialAppearance,
780 })
781 }
782 onUse={() => {
783 const half = singleAppearanceOf(environmentTheme);
784 if (half === null) persistTheme(environmentTheme.id);
785 else assignHalf(half, environmentTheme.id);
786 }}
787 onUseMode={handlePairPick(environmentTheme.id)}
788 theme={getThemeCardDefinition(environmentTheme)}
789 />
790 ))}
791 {customThemeCollections.map(([collectionId, themes]) => (
792 <CustomThemeCollectionCard
793 activeModesFor={pickedModesFor}
794 key={collectionId}
795 onDownload={(customTheme) =>
796 downloadThemeFile(`${customTheme.id}.json`, serializeThemeFile(customTheme))
797 }
798 onDuplicate={(customTheme) =>
799 openThemeEditor({
800 editingThemeId: null,
801 seedThemeId: customTheme.id,
802 seedName: `${customTheme.label} copy`,
803 initialAppearance,
804 })
805 }
806 onEdit={(customTheme) =>
807 openThemeEditor({
808 editingThemeId: customTheme.id,
809 seedThemeId: null,
810 seedName: null,
811 initialAppearance,
812 })
813 }
814 onRemove={(customTheme) => handleRemoveTheme(customTheme, themes)}
815 onUse={(customTheme) => {
816 const modes = getThemeModes(customTheme);
817 if (modes.length === 1) assignHalf(modes[0]!, customTheme.id);
818 else persistTheme(customTheme.id);
819 }}
820 onUseMode={(customTheme, mode) => handlePairPick(customTheme.id)(mode)}
821 themes={themes}
822 />
823 ))}
824 </div>
825 </TooltipProvider>
826 );
827
828 return (
829 <div className="space-y-3">
830 <h3 className="flex min-h-7 items-center px-3 text-base font-semibold text-foreground sm:px-4">
831 {searchableSetting("color-scheme").title}
832 </h3>
833 {renderModeTiles()}
834 <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-5 sm:px-4">
835 <h3 className="text-base font-semibold text-foreground">
836 {searchableSetting("theme").title}
837 </h3>
838 <div className="flex flex-wrap items-center justify-end gap-2">
839 <Button
840 size="xs"
841 variant="outline"
842 onClick={() =>
843 openThemeEditor({
844 editingThemeId: null,
845 seedThemeId: activeThemeForAppearance?.id ?? null,
846 seedName: null,
847 initialAppearance,
848 })
849 }
850 >
851 <PaintbrushIcon />
852 Create theme
853 </Button>
854 <Button size="xs" variant="outline" onClick={() => onImportOpenChange(true)}>
855 <PlusIcon />
856 Add theme
857 </Button>
858 </div>
859 </div>
860 {renderPairGrid()}
861 <ThemeImportDialog
862 onImportedMany={(importedThemes, { updated }) => {
863 if (updated) refreshTheme();
864 const verb = updated ? "updated" : "added";
865 toastManager.add(
866 stackedThreadToast({
867 type: "success",
868 title:
869 importedThemes.length === 1
870 ? `${importedThemes[0]!.label} ${verb}`
871 : `${importedThemes.length} themes ${verb}`,
872 description: importedThemes.map((imported) => imported.label).join(", "),
873 }),
874 );
875 }}
876 onImported={(importedTheme) => {
877 const modes = getThemeModes(importedTheme);
878 if (modes.length === 1) {
879 assignHalf(modes[0]!, importedTheme.id);
880 toastManager.add(
881 stackedThreadToast({
882 type: "success",
883 title: `${importedTheme.label} added`,
884 description: `It’s now your ${modes[0]!} theme.`,
885 }),
886 );
887 return true;
888 }
889 if (!persistTheme(importedTheme.id)) return false;
890 toastManager.add(
891 stackedThreadToast({
892 type: "success",
893 title: `${importedTheme.label} added`,
894 description: "It’s now active.",
895 }),
896 );
897 return true;
898 }}
899 onOpenChange={onImportOpenChange}
900 open={isImportOpen}
901 />
902 <AlertDialog open={isThemeRemovalOpen} onOpenChange={setIsThemeRemovalOpen}>
903 <AlertDialogPopup>
904 <AlertDialogHeader>
905 <AlertDialogTitle>
906 {canRemoveCollection
907 ? `Remove themes from “${removeDialogCollectionLabel}”?`
908 : `Remove “${removeDialogTheme?.label}”?`}
909 </AlertDialogTitle>
910 <AlertDialogDescription>
911 {canRemoveCollection
912 ? "Select the variants you want to remove. You can restore them by importing the extension again."
913 : "You can bring it back anytime by importing its JSON file."}
914 </AlertDialogDescription>
915 </AlertDialogHeader>
916 {canRemoveCollection ? (
917 <div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto px-6 pb-6 sm:grid-cols-2">
918 {removeDialogCollectionThemes.map((customTheme) => {
919 const checked = themeIdsToRemoveSet.has(customTheme.id);
920 const card = getThemeCardDefinition(customTheme);
921 const checkboxId = `remove-theme-${customTheme.id}`;
922 return (
923 <label
924 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"
925 htmlFor={checkboxId}
926 key={customTheme.id}
927 >
928 <span className="absolute right-2 top-2 inline-grid size-5 grid-cols-1 sm:size-4">
929 <input
930 checked={checked}
931 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"
932 id={checkboxId}
933 name="themes-to-remove"
934 type="checkbox"
935 onChange={(event) => {
936 const shouldRemove = event.currentTarget.checked;
937 setThemeIdsToRemove((current) =>
938 shouldRemove
939 ? [...current, customTheme.id]
940 : current.filter((themeId) => themeId !== customTheme.id),
941 );
942 }}
943 />
944 <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" />
945 </span>
946 <span className="flex min-h-12 items-center justify-center gap-1">
947 {card.previews.map((preview) => (
948 <span
949 className="flex size-11 shrink-0 items-center justify-center"
950 key={preview.mode}
951 >
952 <span className="flex scale-75">
953 <ThemePreviewCircle colors={preview.colors} mode={preview.mode} />
954 </span>
955 </span>
956 ))}
957 </span>
958 <p className="max-w-full truncate text-center text-base font-medium text-foreground sm:text-sm">
959 {customTheme.label}
960 </p>
961 </label>
962 );
963 })}
964 </div>
965 ) : null}
966 <AlertDialogFooter>
967 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
968 <Button
969 disabled={themeIdsToRemove.length === 0}
970 variant="destructive"
971 onClick={handleConfirmRemoveTheme}
972 >
973 {canRemoveCollection
974 ? `Remove selected${themeIdsToRemove.length > 0 ? ` (${themeIdsToRemove.length})` : ""}`
975 : "Remove theme"}
976 </Button>
977 </AlertDialogFooter>
978 </AlertDialogPopup>
979 </AlertDialog>
980 </div>
981 );
982}