src/themes.ts

import { z } from "zod";

export const maxCustomThemes = 32;
export const maxThemeFileBytes = 64 * 1024;
export const themeColorSchema = z.string().regex(/^#(?:[\da-f]{3}|[\da-f]{6})$/i)
  .transform((color) => color.length === 4
    ? `#${[...color.slice(1)].map((digit) => digit + digit).join("")}`.toLowerCase()
    : color.toLowerCase());

export const paletteSchema = z.strictObject({
  canvas: themeColorSchema,
  surface: themeColorSchema,
  raised: themeColorSchema,
  text: themeColorSchema,
  secondaryText: themeColorSchema,
  mutedText: themeColorSchema,
  accent: themeColorSchema,
  link: themeColorSchema,
  border: themeColorSchema,
  danger: themeColorSchema,
  reason: themeColorSchema,
  veil: themeColorSchema,
});
export const themeNameSchema = z.string().trim().min(1).max(48);
const palettesSchema = z.strictObject({ dark: paletteSchema, light: paletteSchema });
export const customThemeIdSchema = z.templateLiteral(["custom:", z.string().uuid()]);
export const customThemeSchema = z.strictObject({
  id: customThemeIdSchema,
  name: themeNameSchema,
  palettes: palettesSchema,
});
export const customThemesSchema = z.array(customThemeSchema).max(maxCustomThemes)
  .refine((themes) => new Set(themes.map(({ id }) => id)).size === themes.length);

const themeFileSchema = z.strictObject({
  format: z.literal("emc2-theme"),
  version: z.literal(1),
  name: themeNameSchema,
  palettes: palettesSchema,
});
export type Palette = z.infer<typeof paletteSchema>;
export type CustomTheme = z.infer<typeof customThemeSchema>;
export type ThemePreview = { scheme: "dark" | "light"; palette: Palette };

export const paletteFields = [
  { id: "canvas", label: "Background", variable: "--bg" },
  { id: "accent", label: "Accent", variable: "--accent" },
  { id: "surface", label: "Mail and settings", variable: "--surface" },
  { id: "raised", label: "Menus and controls", variable: "--raised" },
  { id: "text", label: "Primary text", variable: "--text" },
  { id: "secondaryText", label: "Secondary text", variable: "--text-2" },
  { id: "mutedText", label: "Muted text", variable: "--text-3" },
  { id: "link", label: "Links", variable: "--link" },
  { id: "border", label: "Borders", variable: "--line" },
  { id: "reason", label: "Sorting reasons", variable: "--reason" },
  { id: "danger", label: "Errors and destructive actions", variable: "--danger" },
  { id: "veil", label: "Privacy bars", variable: "--veil" },
] as const satisfies readonly { id: keyof Palette; label: string; variable: string }[];

function mix(first: string, second: string, amount: number): string {
  const channel = (offset: number) => Math.round(
    parseInt(first.slice(offset, offset + 2), 16) * (1 - amount) +
    parseInt(second.slice(offset, offset + 2), 16) * amount,
  ).toString(16).padStart(2, "0");
  return `#${channel(1)}${channel(3)}${channel(5)}`;
}

function luminance(color: string): number {
  const channel = (offset: number) => {
    const value = parseInt(color.slice(offset, offset + 2), 16) / 255;
    return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
  };
  return channel(1) * 0.2126 + channel(3) * 0.7152 + channel(5) * 0.0722;
}

export function generatePalette(canvas: string, accent: string, scheme: ThemePreview["scheme"]): Palette {
  const background = themeColorSchema.parse(canvas);
  const highlight = themeColorSchema.parse(accent);
  const surface = mix(background, "#ffffff", scheme === "light" ? 0.75 : 0.045);
  const surfaceLuminance = luminance(surface);
  const light = (surfaceLuminance + 0.05) / (luminance("#1d1d1f") + 0.05) > 1.05 / (surfaceLuminance + 0.05);
  const text = light ? "#1d1d1f" : "#ffffff";
  return {
    canvas: background, accent: highlight, surface,
    raised: mix(surface, text, light ? 0.025 : 0.06), text,
    secondaryText: mix(surface, text, light ? 0.8 : 0.65),
    mutedText: mix(surface, text, light ? 0.65 : 0.55),
    link: mix(highlight, text, light ? 0.12 : 0.28),
    border: mix(surface, text, light ? 0.16 : 0.12),
    danger: light ? "#c93434" : "#f07c7c",
    reason: light ? "#a4512f" : "#e3a08b",
    veil: mix(surface, text, light ? 0.2 : 0.1),
  };
}

export function applyPalette(palette: Palette, root: HTMLElement): void {
  for (const field of paletteFields) root.style.setProperty(field.variable, palette[field.id]);
  root.style.setProperty("--text-body", palette.text);
  root.style.setProperty("--text-4", palette.mutedText);
  root.style.setProperty("--line-2", `color-mix(in srgb, ${palette.border} 80%, ${palette.text})`);
  root.style.setProperty("--line-3", `color-mix(in srgb, ${palette.border} 65%, ${palette.text})`);
  root.style.setProperty("--hover", `color-mix(in srgb, ${palette.text} 4%, transparent)`);
  root.style.setProperty("--selected", `color-mix(in srgb, ${palette.text} 7%, transparent)`);
  root.style.setProperty("--veil-hover", `color-mix(in srgb, ${palette.veil} 90%, ${palette.text})`);
  root.style.setProperty("--avatar", palette.raised);
  root.style.setProperty("--avatar-text", palette.secondaryText);
}

export function clearPalette(root: HTMLElement): void {
  for (const field of paletteFields) root.style.removeProperty(field.variable);
  for (const variable of ["--text-body", "--text-4", "--line-2", "--line-3", "--hover", "--selected", "--veil-hover", "--avatar", "--avatar-text"])
    root.style.removeProperty(variable);
}

export function parseThemeFile(text: string): Omit<CustomTheme, "id"> {
  if (new TextEncoder().encode(text).length > maxThemeFileBytes)
    throw new Error("Theme files must be 64 KB or smaller.");
  let input: unknown;
  try { input = JSON.parse(text); } catch { throw new Error("Choose a valid JSON theme file."); }
  const result = themeFileSchema.safeParse(input);
  if (!result.success)
    throw new Error("Use an Emc² version 1 theme with a name, light and dark palettes, and hex colors (#RGB or #RRGGBB).");
  return { name: result.data.name, palettes: result.data.palettes };
}

export function serializeThemeFile(theme: CustomTheme): string {
  return `${JSON.stringify(themeFileSchema.parse({ format: "emc2-theme", version: 1, name: theme.name, palettes: theme.palettes }), null, 2)}\n`;
}

export function uniqueThemeName(name: string, themes: readonly CustomTheme[]): string {
  const names = new Set(themes.map((theme) => theme.name.toLowerCase()));
  let candidate = name.slice(0, 48);
  for (let copy = 2; names.has(candidate.toLowerCase()); copy++) {
    const suffix = ` (${copy})`;
    candidate = `${name.slice(0, 48 - suffix.length)}${suffix}`;
  }
  return candidate;
}