1import { MoonIcon, SunIcon } from "lucide-react";
2import type { CSSProperties } from "react";
3import {
4 STANDARD_THEME_PREVIEW_COLORS as SHARED_STANDARD_THEME_PREVIEW_COLORS,
5 THEME_PREVIEW_RENDER_SPECS,
6} from "@t3tools/shared/themePreview";
7import { cn } from "../../lib/utils";
8import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
9import {
10 getThemeColorsForMode,
11 getThemeModes,
12 type ThemeAppearance,
13 type ThemeDefinition,
14} from "../../themePalette";
15
16const THEME_PREVIEW_ROLES = [
17 "sidebar",
18 "canvas",
19 "surface",
20 "accentSurface",
21 "accent",
22 "messageSurface",
23 "messageAction",
24] as const;
25type ThemePreviewRole = (typeof THEME_PREVIEW_ROLES)[number];
26type ThemeCardPreview = {
27 mode: ThemeAppearance;
28 colors: Readonly<Record<ThemePreviewRole, string>>;
29};
30export type ThemeCardDefinition = {
31 id: string;
32 label: string;
33 previews: ReadonlyArray<ThemeCardPreview>;
34};
35export type ThemeMode = ThemeAppearance | "system";
36export type ThemeCardPreviewColors = ThemeCardPreview["colors"];
37
38const STANDARD_THEME_PREVIEW_COLORS: Record<
39 ThemeAppearance,
40 Readonly<Record<ThemePreviewRole, string>>
41> = {
42 light: {
43 sidebar: "#fafafa",
44 surface: "#ffffff",
45 accentSurface: "#f4f4f5",
46 messageSurface: "#e4e4e7",
47 ...SHARED_STANDARD_THEME_PREVIEW_COLORS.light,
48 },
49 dark: {
50 sidebar: "#0f0f10",
51 surface: "#121212",
52 accentSurface: "#27272a",
53 messageSurface: "#27272a",
54 ...SHARED_STANDARD_THEME_PREVIEW_COLORS.dark,
55 },
56};
57
58export const STANDARD_THEME_CARDS: ReadonlyArray<ThemeCardDefinition> = [
59 {
60 id: "default",
61 label: "T3 Code",
62 previews: (["light", "dark"] as const).map((mode) => ({
63 mode,
64 colors: STANDARD_THEME_PREVIEW_COLORS[mode],
65 })),
66 },
67];
68
69export function previewColorsOf(
70 card: ThemeCardDefinition,
71 mode: ThemeAppearance,
72): ThemeCardPreviewColors | null {
73 return card.previews.find((preview) => preview.mode === mode)?.colors ?? null;
74}
75
76export function getThemeCardDefinition(theme: ThemeDefinition): ThemeCardDefinition {
77 return {
78 id: theme.id,
79 label: theme.label,
80 previews: getThemeModes(theme).map((mode) => {
81 const colors = getThemeColorsForMode(theme, mode) ?? theme.colors;
82 return {
83 mode,
84 colors: {
85 sidebar: colors.sidebar,
86 canvas: colors.canvas,
87 surface: colors.surface,
88 accentSurface: colors.accentSurface,
89 accent: colors.accent,
90 messageSurface: colors.messageSurface,
91 messageAction: colors.messageAction,
92 },
93 };
94 }),
95 };
96}
97
98// Interpolating in oklab keeps the glow falloff perceptually even (no gray
99// mid-tones or banding rings), and premultiplied alpha keeps the fade to
100// transparent clean.
101function getThemePreviewStyle(
102 colors: ThemeCardPreviewColors,
103 mode: ThemeAppearance,
104): CSSProperties {
105 const spec = THEME_PREVIEW_RENDER_SPECS[mode];
106 // The canvas carries the ball's light/dark identity, so it stays dominant:
107 // a near-true base with a contained accent glow, instead of an accent wash
108 // that makes both modes read alike.
109 const modeBase = `color-mix(in oklab, ${colors.canvas} ${spec.baseWeight * 100}%, ${spec.baseTarget})`;
110 const accentPosition = `${spec.accent.center[0] * 100}% ${spec.accent.center[1] * 100}%`;
111 const actionPosition = `${spec.action.center[0] * 100}% ${spec.action.center[1] * 100}%`;
112 return {
113 backgroundColor: modeBase,
114 backgroundImage: [
115 `radial-gradient(circle at ${accentPosition} in oklab, ${colors.accent} 0%, color-mix(in oklab, ${colors.accent} ${spec.accent.middleOpacity * 100}%, transparent) ${spec.accent.middleOffset * 100}%, transparent ${spec.accent.endOffset * 100}%)`,
116 // The action color is a soft tint from the opposite corner, not a second
117 // light source — two bright hotspots read as headlights.
118 `radial-gradient(circle at ${actionPosition} in oklab, color-mix(in oklab, ${colors.messageAction} ${spec.action.startOpacity * 100}%, transparent) 0%, transparent ${spec.action.endOffset * 100}%)`,
119 ].join(", "),
120 };
121}
122
123// The gradient halves of each ball can match the card surface, so every ball
124// carries a faint mode-appropriate inner ring to keep its silhouette legible.
125function themePreviewEdgeShadow(mode: ThemeAppearance): string {
126 return mode === "dark"
127 ? "inset 0 0 0 1px rgb(255 255 255 / 0.14), 0 1px 2px rgb(0 0 0 / 0.18)"
128 : "inset 0 0 0 1px rgb(0 0 0 / 0.10), 0 1px 2px rgb(0 0 0 / 0.08)";
129}
130
131export function ThemePreviewCircle({
132 colors,
133 mode,
134 className,
135}: {
136 colors: ThemeCardPreviewColors;
137 mode: ThemeAppearance;
138 className?: string;
139}) {
140 return (
141 <span
142 aria-hidden
143 className={cn(
144 "relative block size-14 shrink-0 overflow-hidden rounded-full border-2 border-background",
145 className,
146 )}
147 style={{ boxShadow: themePreviewEdgeShadow(mode) }}
148 >
149 <span
150 className="absolute inset-0 rounded-full"
151 style={{
152 ...getThemePreviewStyle(colors, mode),
153 filter: `blur(${THEME_PREVIEW_RENDER_SPECS[mode].blurAt56Px}px)`,
154 transform: `scale(${THEME_PREVIEW_RENDER_SPECS[mode].scale})`,
155 }}
156 />
157 </span>
158 );
159}
160
161/**
162 * A theme card's light and dark balls. Clicking a ball assigns that theme to
163 * that half of the appearance mix; assigned balls carry a ring and a sun or
164 * moon badge.
165 */
166export function ThemePreviewCircles({
167 label,
168 activeModes,
169 onSelectMode,
170 previews,
171}: {
172 label: string;
173 activeModes: ReadonlyArray<ThemeMode>;
174 onSelectMode: (mode: ThemeMode) => void;
175 previews: ThemeCardDefinition["previews"];
176}) {
177 return (
178 <div className="flex min-h-16 items-center justify-center gap-2.5 px-3 pt-3">
179 {previews.map((preview) => {
180 const mode = preview.mode;
181 const isPicked = activeModes.includes(mode);
182 return (
183 <Tooltip key={mode}>
184 <TooltipTrigger
185 render={
186 <button
187 aria-label={`Use ${label} ${mode} mode`}
188 aria-pressed={isPicked}
189 className={cn(
190 "relative flex size-[68px] shrink-0 transform-gpu cursor-pointer items-center justify-center rounded-full p-1 outline-none transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
191 isPicked && "hover:scale-100",
192 )}
193 onClick={(event) => {
194 event.stopPropagation();
195 onSelectMode(mode);
196 }}
197 type="button"
198 >
199 <ThemePreviewCircle colors={preview.colors} mode={mode} />
200 {isPicked ? (
201 <>
202 <span
203 aria-hidden
204 className="pointer-events-none absolute inset-0 rounded-full"
205 style={{ boxShadow: "inset 0 0 0 2px var(--ring)" }}
206 />
207 <span
208 aria-hidden
209 className="pointer-events-none absolute bottom-0.5 right-0.5 flex size-5 items-center justify-center rounded-full border border-border/70 bg-background text-foreground shadow-sm"
210 >
211 {mode === "light" ? (
212 <SunIcon className="size-3" />
213 ) : (
214 <MoonIcon className="size-3" />
215 )}
216 </span>
217 </>
218 ) : null}
219 </button>
220 }
221 />
222 <TooltipPopup>
223 {mode === "light" ? "Use for light mode only" : "Use for dark mode only"}
224 </TooltipPopup>
225 </Tooltip>
226 );
227 })}
228 </div>
229 );
230}