Skip to content
Source
Sign inSign up
Open History to see the last commit

ThemePreviewCircles.tsx

215 lines6.9 KB

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;
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 };
97​
98function getThemePreviewStyle(
99 colors: ThemeCardPreviewColors,
100 mode: ThemeAppearance,
101): CSSProperties {
102 const spec = THEME_PREVIEW_RENDER_SPECS[mode];
103 const modeBase = `color-mix(in oklab, ${colors.canvas} ${spec.baseWeight * 100}%, ${spec.baseTarget})`;
104 const accentPosition = `${spec.accent.center[0] * 100}% ${spec.accent.center[1] * 100}%`;
105 const actionPosition = `${spec.action.center[0] * 100}% ${spec.action.center[1] * 100}%`;
106 return {
107 backgroundColor: modeBase,
108 backgroundImage: [
109 `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}%)`,
110 `radial-gradient(circle at ${actionPosition} in oklab, color-mix(in oklab, ${colors.messageAction} ${spec.action.startOpacity * 100}%, transparent) 0%, transparent ${spec.action.endOffset * 100}%)`,
111 ].join(", "),
112 };
114​
115function themePreviewEdgeShadow(mode: ThemeAppearance): string {
116 return mode === "dark"
117 ? "inset 0 0 0 1px rgb(255 255 255 / 0.14), 0 1px 2px rgb(0 0 0 / 0.18)"
118 : "inset 0 0 0 1px rgb(0 0 0 / 0.10), 0 1px 2px rgb(0 0 0 / 0.08)";
120​
121export function ThemePreviewCircle({
122 colors,
123 mode,
124 className,
125}: {
126 colors: ThemeCardPreviewColors;
127 mode: ThemeAppearance;
128 className?: string;
129}) {
130 return (
131 <span
132 aria-hidden
133 className={cn(
134 "relative block size-14 shrink-0 overflow-hidden rounded-full border-2 border-background",
135 className,
136 )}
137 style={{ boxShadow: themePreviewEdgeShadow(mode) }}
138 >
139 <span
140 className="absolute inset-0 rounded-full"
141 style={{
142 ...getThemePreviewStyle(colors, mode),
143 filter: `blur(${THEME_PREVIEW_RENDER_SPECS[mode].blurAt56Px}px)`,
144 transform: `scale(${THEME_PREVIEW_RENDER_SPECS[mode].scale})`,
145 }}
146 />
147 </span>
148 );
150​
151export function ThemePreviewCircles({
152 label,
153 activeModes,
154 onSelectMode,
155 previews,
156}: {
157 label: string;
158 activeModes: ReadonlyArray<ThemeMode>;
159 onSelectMode: (mode: ThemeMode) => void;
160 previews: ThemeCardDefinition["previews"];
161}) {
162 return (
163 <div className="flex min-h-16 items-center justify-center gap-2.5 px-3 pt-3">
164 {previews.map((preview) => {
165 const mode = preview.mode;
166 const isPicked = activeModes.includes(mode);
167 return (
168 <Tooltip key={mode}>
169 <TooltipTrigger
170 render={
171 <button
172 aria-label={`Use ${label} ${mode} mode`}
173 aria-pressed={isPicked}
174 className={cn(
175 "relative flex size-[68px] shrink-0 transform-gpu cursor-pointer items-center justify-center rounded-full p-1.5 outline-none transition-transform hover:scale-105 focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-5 focus-visible:outline-foreground/60",
176 isPicked && "hover:scale-100",
177 )}
178 onClick={(event) => {
179 event.stopPropagation();
180 onSelectMode(mode);
181 }}
182 type="button"
183 >
184 <ThemePreviewCircle colors={preview.colors} mode={mode} />
185 {isPicked ? (
186 <>
187 <span
188 aria-hidden
189 className="pointer-events-none absolute inset-0 rounded-full"
190 style={{ boxShadow: "inset 0 0 0 2px var(--ring)" }}
191 />
192 <span
193 aria-hidden
194 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"
195 >
196 {mode === "light" ? (
197 <SunIcon className="size-3" />
198 ) : (
199 <MoonIcon className="size-3" />
200 )}
201 </span>
202 </>
203 ) : null}
204 </button>
205 }
206 />
207 <TooltipPopup>
208 {mode === "light" ? "Use for light mode only" : "Use for dark mode only"}
209 </TooltipPopup>
210 </Tooltip>
211 );
212 })}
213 </div>
214 );