import type { Theme, ThemeComponentColors } from "@yaakapp-internal/plugins"; import { defaultDarkTheme, defaultLightTheme } from "./defaultThemes"; import { YaakColor } from "./yaakColor"; export type YaakColors = { surface: YaakColor; surfaceHighlight?: YaakColor; surfaceActive?: YaakColor; text: YaakColor; textSubtle?: YaakColor; textSubtlest?: YaakColor; border?: YaakColor; borderSubtle?: YaakColor; borderFocus?: YaakColor; shadow?: YaakColor; backdrop?: YaakColor; selection?: YaakColor; primary?: YaakColor; secondary?: YaakColor; info?: YaakColor; success?: YaakColor; notice?: YaakColor; warning?: YaakColor; danger?: YaakColor; }; export type YaakTheme = { id: string; name: string; base: YaakColors; components?: Partial<{ dialog: Partial; menu: Partial; toast: Partial; sidebar: Partial; responsePane: Partial; appHeader: Partial; button: Partial; banner: Partial; templateTag: Partial; urlBar: Partial; editor: Partial; input: Partial; }>; }; export type YaakColorKey = keyof ThemeComponentColors; export type DocumentPlatform = "linux" | "macos" | "windows" | "unknown"; type ComponentName = keyof NonNullable; type CSSVariables = Record; function themeVariables( theme: Theme, component?: ComponentName, base?: CSSVariables, ): CSSVariables | null { const cmp = component == null ? theme.base : (theme.components?.[component] ?? ({} as ThemeComponentColors)); const color = (value: string | undefined) => yc(theme, value); const vars: CSSVariables = { surface: cmp.surface, surfaceHighlight: cmp.surfaceHighlight ?? color(cmp.surface)?.lift(0.06).css(), surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(), backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(), selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(), border: cmp.border ?? color(cmp.surface)?.lift(0.11)?.css(), borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06)?.css(), borderFocus: color(cmp.info)?.translucify(0.5)?.css(), text: cmp.text, textSubtle: cmp.textSubtle ?? color(cmp.text)?.lower(0.2)?.css(), textSubtlest: cmp.textSubtlest ?? color(cmp.text)?.lower(0.3)?.css(), shadow: cmp.shadow ?? YaakColor.black() .translucify(theme.dark ? 0.7 : 0.93) .css(), primary: cmp.primary, secondary: cmp.secondary, info: cmp.info, success: cmp.success, notice: cmp.notice, warning: cmp.warning, danger: cmp.danger, }; for (const [key, value] of Object.entries(vars)) { if (!value && base?.[key as YaakColorKey]) { vars[key as YaakColorKey] = base[key as YaakColorKey]; } } return vars; } function templateTagColorVariables( color: YaakColor | null, ): Partial { if (color == null) return {}; return { text: color.lift(0.7).css(), textSubtle: color.lift(0.4).css(), textSubtlest: color.css(), surface: color.lower(0.2).translucify(0.8).css(), border: color.translucify(0.6).css(), borderSubtle: color.translucify(0.8).css(), surfaceHighlight: color.lower(0.1).translucify(0.7).css(), }; } function toastColorVariables(color: YaakColor | null): Partial { if (color == null) return {}; return { text: color.lift(0.8).css(), textSubtle: color.lift(0.8).translucify(0.3).css(), surface: color.translucify(0.9).css(), surfaceHighlight: color.translucify(0.8).css(), border: color.lift(0.3).translucify(0.6).css(), }; } function bannerColorVariables(color: YaakColor | null): Partial { if (color == null) return {}; return { text: color.lift(0.8).css(), textSubtle: color.translucify(0.3).css(), textSubtlest: color.translucify(0.6).css(), surface: color.translucify(0.95).css(), border: color.lift(0.3).translucify(0.8).css(), }; } function _inputCSS(color: YaakColor | null): Partial { if (color == null) return {}; const theme: Partial = { border: color.css(), }; return theme; } function buttonSolidColorVariables( color: YaakColor | null, isDefault = false, ): Partial { if (color == null) return {}; const theme: Partial = { text: "white", surface: color.lower(0.3).css(), surfaceHighlight: color.lower(0.1).css(), border: color.css(), }; if (isDefault) { theme.text = undefined; theme.surface = undefined; theme.surfaceHighlight = color.lift(0.08).css(); } return theme; } function buttonBorderColorVariables( color: YaakColor | null, isDefault = false, ): Partial { if (color == null) return {}; const vars: Partial = { text: color.lift(0.8).css(), textSubtle: color.lift(0.55).css(), textSubtlest: color.lift(0.4).translucify(0.6).css(), surfaceHighlight: color.translucify(0.8).css(), borderSubtle: color.translucify(0.5).css(), border: color.translucify(0.3).css(), }; if (isDefault) { vars.borderSubtle = color.lift(0.28).css(); vars.border = color.lift(0.5).css(); } return vars; } function variablesToCSS( selector: string | null, vars: Partial | null, ): string | null { if (vars == null) return null; const css = Object.entries(vars) .filter(([, value]) => value) .map(([name, value]) => `--${name}: ${value};`) .join("\n"); return selector == null ? css : `${selector} {\n${indent(css)}\n}`; } function componentCSS(theme: Theme, component: ComponentName): string | null { if (theme.components == null) return null; return variablesToCSS( `.x-theme-${component}`, themeVariables(theme, component), ); } function buttonCSS( theme: Theme, colorKey: YaakColorKey, colors?: ThemeComponentColors, ): string | null { const color = yc(theme, colors?.[colorKey]); if (color == null) return null; return [ variablesToCSS( `.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(color), ), variablesToCSS( `.x-theme-button--border--${colorKey}`, buttonBorderColorVariables(color), ), ].join("\n\n"); } function bannerCSS( theme: Theme, colorKey: YaakColorKey, colors?: ThemeComponentColors, ): string | null { const color = yc(theme, colors?.[colorKey]); if (color == null) return null; return variablesToCSS( `.x-theme-banner--${colorKey}`, bannerColorVariables(color), ); } function toastCSS( theme: Theme, colorKey: YaakColorKey, colors?: ThemeComponentColors, ): string | null { const color = yc(theme, colors?.[colorKey]); if (color == null) return null; return variablesToCSS( `.x-theme-toast--${colorKey}`, toastColorVariables(color), ); } function templateTagCSS( theme: Theme, colorKey: YaakColorKey, colors?: ThemeComponentColors, ): string | null { const color = yc(theme, colors?.[colorKey]); if (color == null) return null; return variablesToCSS( `.x-theme-templateTag--${colorKey}`, templateTagColorVariables(color), ); } export function getThemeCSS(theme: Theme): string { theme.components = theme.components ?? {}; theme.components.toast = theme.components.toast ?? theme.components.menu ?? {}; const { components, id, label } = theme; const colors = Object.keys(theme.base).reduce((prev, key) => { return { ...prev, [key]: theme.base[key as YaakColorKey] }; }, {} as ThemeComponentColors); let themeCSS = ""; try { const baseCss = variablesToCSS(null, themeVariables(theme)); themeCSS = [ baseCss, ...Object.keys(components).map((key) => componentCSS(theme, key as ComponentName), ), variablesToCSS( ".x-theme-button--solid--default", buttonSolidColorVariables(yc(theme, theme.base.surface), true), ), variablesToCSS( ".x-theme-button--border--default", buttonBorderColorVariables(yc(theme, theme.base.surface), true), ), ...Object.keys(colors).map((key) => buttonCSS( theme, key as YaakColorKey, theme.components?.button ?? colors, ), ), ...Object.keys(colors).map((key) => bannerCSS( theme, key as YaakColorKey, theme.components?.banner ?? colors, ), ), ...Object.keys(colors).map((key) => toastCSS( theme, key as YaakColorKey, theme.components?.banner ?? colors, ), ), ...Object.keys(colors).map((key) => templateTagCSS( theme, key as YaakColorKey, theme.components?.templateTag ?? colors, ), ), ].join("\n\n"); } catch (err) { console.error("Failed to generate CSS", err); } return [ `/* ${label} */`, `[data-theme="${id}"] {`, indent(themeCSS), "}", ].join("\n"); } export function addThemeStylesToDocument(rawTheme: Theme | null) { if (rawTheme == null) { console.error("Failed to add theme styles: theme is null"); return; } const theme = completeTheme(rawTheme); let styleEl = document.head.querySelector("style[data-theme]"); if (!styleEl) { styleEl = document.createElement("style"); document.head.appendChild(styleEl); } styleEl.setAttribute("data-theme", theme.id); styleEl.setAttribute("data-updated-at", new Date().toISOString()); styleEl.textContent = getThemeCSS(theme); } export function setThemeOnDocument(theme: Theme | null) { if (theme == null) { console.error("Failed to set theme: theme is null"); return; } document.documentElement.setAttribute("data-theme", theme.id); } export function applyThemeToDocument(theme: Theme | null) { addThemeStylesToDocument(theme); setThemeOnDocument(theme); } export function platformFromUserAgent(userAgent: string): DocumentPlatform { const normalized = userAgent.toLowerCase(); if (normalized.includes("linux")) return "linux"; if (normalized.includes("mac os") || normalized.includes("macintosh")) return "macos"; if (normalized.includes("win")) return "windows"; return "unknown"; } export function setPlatformOnDocument(platform: string | null | undefined) { const normalized = platform === "linux" || platform === "macos" || platform === "windows" ? platform : "unknown"; document.documentElement.setAttribute("data-platform", normalized); } export function indent(text: string, space = " "): string { return text .split("\n") .map((line) => space + line) .join("\n"); } function yc( theme: Theme, value: T, ): T extends string ? YaakColor : null { if (value == null) return null as never; return new YaakColor(value, theme.dark ? "dark" : "light") as never; } export function completeTheme(theme: Theme): Theme { const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base; const color = (value: string | null | undefined) => yc(theme, value); theme.base.primary ??= fallback.primary; theme.base.secondary ??= fallback.secondary; theme.base.info ??= fallback.info; theme.base.success ??= fallback.success; theme.base.notice ??= fallback.notice; theme.base.warning ??= fallback.warning; theme.base.danger ??= fallback.danger; theme.base.surface ??= fallback.surface; theme.base.surfaceHighlight ??= color(theme.base.surface)?.lift(0.06)?.css(); theme.base.surfaceActive ??= color(theme.base.primary) ?.lower(0.2) .translucify(0.8) .css(); theme.base.border ??= color(theme.base.surface)?.lift(0.12)?.css(); theme.base.borderSubtle ??= color(theme.base.border)?.lower(0.08)?.css(); theme.base.text ??= fallback.text; theme.base.textSubtle ??= color(theme.base.text)?.lower(0.3)?.css(); theme.base.textSubtlest ??= color(theme.base.text)?.lower(0.5)?.css(); return theme; }