225 lines · 7.7 KB · TypeScript
RawDownload
import { MoonIcon, SunIcon } from "lucide-react";import type { CSSProperties } from "react";import {  STANDARD_THEME_PREVIEW_COLORS as SHARED_STANDARD_THEME_PREVIEW_COLORS,  THEME_PREVIEW_RENDER_SPECS,} from "@t3tools/shared/themePreview";import { cn } from "../../lib/utils";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import {  getThemeColorsForMode,  getThemeModes,  type ThemeAppearance,  type ThemeDefinition,} from "../../themePalette"; const THEME_PREVIEW_ROLES = [  "sidebar",  "canvas",  "surface",  "accentSurface",  "accent",  "messageSurface",  "messageAction",] as const;type ThemePreviewRole = (typeof THEME_PREVIEW_ROLES)[number];type ThemeCardPreview = {  mode: ThemeAppearance;  colors: Readonly<Record<ThemePreviewRole, string>>;};export type ThemeCardDefinition = {  id: string;  label: string;  previews: ReadonlyArray<ThemeCardPreview>;};export type ThemeMode = ThemeAppearance | "system";export type ThemeCardPreviewColors = ThemeCardPreview["colors"]; const STANDARD_THEME_PREVIEW_COLORS: Record<  ThemeAppearance,  Readonly<Record<ThemePreviewRole, string>>> = {  light: {    sidebar: "#fafafa",    surface: "#ffffff",    accentSurface: "#f4f4f5",    messageSurface: "#e4e4e7",    ...SHARED_STANDARD_THEME_PREVIEW_COLORS.light,  },  dark: {    sidebar: "#0f0f10",    surface: "#121212",    accentSurface: "#27272a",    messageSurface: "#27272a",    ...SHARED_STANDARD_THEME_PREVIEW_COLORS.dark,  },}; export const STANDARD_THEME_CARDS: ReadonlyArray<ThemeCardDefinition> = [  {    id: "default",    label: "T3 Code",    previews: (["light", "dark"] as const).map((mode) => ({      mode,      colors: STANDARD_THEME_PREVIEW_COLORS[mode],    })),  },]; export function previewColorsOf(  card: ThemeCardDefinition,  mode: ThemeAppearance,): ThemeCardPreviewColors | null {  return card.previews.find((preview) => preview.mode === mode)?.colors ?? null;} export function getThemeCardDefinition(theme: ThemeDefinition): ThemeCardDefinition {  return {    id: theme.id,    label: theme.label,    previews: getThemeModes(theme).map((mode) => {      const colors = getThemeColorsForMode(theme, mode) ?? theme.colors;      return {        mode,        colors: {          sidebar: colors.sidebar,          canvas: colors.canvas,          surface: colors.surface,          accentSurface: colors.accentSurface,          accent: colors.accent,          messageSurface: colors.messageSurface,          messageAction: colors.messageAction,        },      };    }),  };} // Interpolating in oklab keeps the glow falloff perceptually even (no gray// mid-tones or banding rings), and premultiplied alpha keeps the fade to// transparent clean.function getThemePreviewStyle(  colors: ThemeCardPreviewColors,  mode: ThemeAppearance,): CSSProperties {  const spec = THEME_PREVIEW_RENDER_SPECS[mode];  // The canvas carries the ball's light/dark identity, so it stays dominant:  // a near-true base with a contained accent glow, instead of an accent wash  // that makes both modes read alike.  const modeBase = `color-mix(in oklab, ${colors.canvas} ${spec.baseWeight * 100}%, ${spec.baseTarget})`;  const accentPosition = `${spec.accent.center[0] * 100}% ${spec.accent.center[1] * 100}%`;  const actionPosition = `${spec.action.center[0] * 100}% ${spec.action.center[1] * 100}%`;  return {    backgroundColor: modeBase,    backgroundImage: [      `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}%)`,      // The action color is a soft tint from the opposite corner, not a second      // light source — two bright hotspots read as headlights.      `radial-gradient(circle at ${actionPosition} in oklab, color-mix(in oklab, ${colors.messageAction} ${spec.action.startOpacity * 100}%, transparent) 0%, transparent ${spec.action.endOffset * 100}%)`,    ].join(", "),  };} // The gradient halves of each ball can match the card surface, so every ball// carries a faint mode-appropriate inner ring to keep its silhouette legible.function themePreviewEdgeShadow(mode: ThemeAppearance): string {  return mode === "dark"    ? "inset 0 0 0 1px rgb(255 255 255 / 0.14), 0 1px 2px rgb(0 0 0 / 0.18)"    : "inset 0 0 0 1px rgb(0 0 0 / 0.10), 0 1px 2px rgb(0 0 0 / 0.08)";} export function ThemePreviewCircle({  colors,  mode,}: {  colors: ThemeCardPreviewColors;  mode: ThemeAppearance;}) {  return (    <span      aria-hidden      className="relative block size-14 shrink-0 overflow-hidden rounded-full border-2 border-background"      style={{ boxShadow: themePreviewEdgeShadow(mode) }}    >      <span        className="absolute inset-0 rounded-full"        style={{          ...getThemePreviewStyle(colors, mode),          filter: `blur(${THEME_PREVIEW_RENDER_SPECS[mode].blurAt56Px}px)`,          transform: `scale(${THEME_PREVIEW_RENDER_SPECS[mode].scale})`,        }}      />    </span>  );} /** * A theme card's light and dark balls. Clicking a ball assigns that theme to * that half of the appearance mix; assigned balls carry a ring and a sun or * moon badge. */export function ThemePreviewCircles({  label,  activeModes,  onSelectMode,  previews,}: {  label: string;  activeModes: ReadonlyArray<ThemeMode>;  onSelectMode: (mode: ThemeMode) => void;  previews: ThemeCardDefinition["previews"];}) {  return (    <div className="flex min-h-16 items-center justify-center gap-2.5 px-3 pt-3">      {previews.map((preview) => {        const mode = preview.mode;        const isPicked = activeModes.includes(mode);        return (          <Tooltip key={mode}>            <TooltipTrigger              render={                <button                  aria-label={`Use ${label} ${mode} mode`}                  aria-pressed={isPicked}                  className={cn(                    "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-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",                    isPicked && "hover:scale-100",                  )}                  onClick={(event) => {                    event.stopPropagation();                    onSelectMode(mode);                  }}                  type="button"                >                  <ThemePreviewCircle colors={preview.colors} mode={mode} />                  {isPicked ? (                    <>                      <span                        aria-hidden                        className="pointer-events-none absolute inset-0 rounded-full"                        style={{ boxShadow: "inset 0 0 0 2px var(--ring)" }}                      />                      <span                        aria-hidden                        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"                      >                        {mode === "light" ? (                          <SunIcon className="size-3" />                        ) : (                          <MoonIcon className="size-3" />                        )}                      </span>                    </>                  ) : null}                </button>              }            />            <TooltipPopup>              {mode === "light" ? "Use for light mode only" : "Use for dark mode only"}            </TooltipPopup>          </Tooltip>        );      })}    </div>  );}