| 1 | import { MoonIcon, SunIcon } from "lucide-react"; |
| 2 | import type { CSSProperties } from "react"; |
| 3 | import { |
| 4 | STANDARD_THEME_PREVIEW_COLORS as SHARED_STANDARD_THEME_PREVIEW_COLORS, |
| 5 | THEME_PREVIEW_RENDER_SPECS, |
| 6 | } from "@t3tools/shared/themePreview"; |
| 7 | import { cn } from "../../lib/utils"; |
| 8 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 9 | import { |
| 10 | getThemeColorsForMode, |
| 11 | getThemeModes, |
| 12 | type ThemeAppearance, |
| 13 | type ThemeDefinition, |
| 14 | } from "../../themePalette"; |
| 15 | |
| 16 | const THEME_PREVIEW_ROLES = [ |
| 17 | "sidebar", |
| 18 | "canvas", |
| 19 | "surface", |
| 20 | "accentSurface", |
| 21 | "accent", |
| 22 | "messageSurface", |
| 23 | "messageAction", |
| 24 | ] as const; |
| 25 | type ThemePreviewRole = (typeof THEME_PREVIEW_ROLES)[number]; |
| 26 | type ThemeCardPreview = { |
| 27 | mode: ThemeAppearance; |
| 28 | colors: Readonly<Record<ThemePreviewRole, string>>; |
| 29 | }; |
| 30 | export type ThemeCardDefinition = { |
| 31 | id: string; |
| 32 | label: string; |
| 33 | previews: ReadonlyArray<ThemeCardPreview>; |
| 34 | }; |
| 35 | export type ThemeMode = ThemeAppearance | "system"; |
| 36 | export type ThemeCardPreviewColors = ThemeCardPreview["colors"]; |
| 37 | |
| 38 | const 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 | |
| 58 | export 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 | |
| 69 | export function previewColorsOf( |
| 70 | card: ThemeCardDefinition, |
| 71 | mode: ThemeAppearance, |
| 72 | ): ThemeCardPreviewColors | null { |
| 73 | return card.previews.find((preview) => preview.mode === mode)?.colors ?? null; |
| 74 | } |
| 75 | |
| 76 | export 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. |
| 101 | function 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. |
| 125 | function 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 | |
| 131 | export 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 | */ |
| 166 | export 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 | } |