/** * Theme Loader * * Loads Shiki theme JSON files and extracts key colors (bg, fg, comment, git). * Only imports the theme JSON — the Shiki highlighter engine is not used here. */ import type { ThemeRegistrationRaw } from "shiki"; import { type TerminalPalette, extractTerminalPalette, } from "./terminal-palette"; /** * Buzz theme name. Buzz is a first-party light theme that reuses GitHub * Light for every base color (backgrounds, text, borders, code) — the * message area and containers are indistinguishable from GitHub Light. Its * one distinguishing feature is a branded gradient painted across the * sidebar/nav canvas, replacing GitHub Light's flat grey. The gradient is * applied by {@link ThemeProvider} toggling a `data-buzz-sidebar` attribute * on the document root; the CSS lives in `shared/styles/globals/theme.css`. */ export const BUZZ_THEME_NAME = "buzz"; /** * Buzz Dark theme name. The dark-mode counterpart to {@link BUZZ_THEME_NAME}: * reuses the GitHub Dark palette for every base color, with the same branded * sidebar gradient (dark-tuned colors, see `shared/styles/globals/theme.css`). * {@link ThemeProvider} toggles the shared `data-buzz-sidebar` attribute for * this theme too; the `.dark` root class selects the dark gradient values. * * Buzz and Buzz Dark are paired in {@link THEME_PAIRS}, so the picker shows a * combined "Buzz" tile under System mode (follow-OS) plus a single "Buzz" tile * under Light and a "Buzz Dark" tile under Dark. */ export const BUZZ_DARK_THEME_NAME = "buzz-dark"; /** The Shiki bundle Buzz borrows its base palette from. */ export const BUZZ_BASE_THEME: SyntaxThemeName = "github-light"; /** The Shiki bundle Buzz Dark borrows its base palette from. */ export const BUZZ_DARK_BASE_THEME: SyntaxThemeName = "github-dark"; /** * Resolve a theme name to the real Shiki bundled theme it maps to. * * Most themes map to themselves, but the Buzz aliases (`buzz` / `buzz-dark`) * are not bundled Shiki themes — they reuse the GitHub Light / GitHub Dark * palettes. The Shiki highlighter engine (used for fenced code blocks in * `CodeBlock.tsx`) only understands bundled names, so callers that hand a * theme name to `loadTheme` / `codeToTokens` must resolve it through here * first; passing a raw Buzz alias makes Shiki throw and code blocks fall * back to unhighlighted plain text. */ export function resolveShikiThemeName(name: string): SyntaxThemeName { if (name === BUZZ_THEME_NAME) return BUZZ_BASE_THEME; if (name === BUZZ_DARK_THEME_NAME) return BUZZ_DARK_BASE_THEME; return name as SyntaxThemeName; } // Available themes. "buzz" is a Buzz-branded theme that reuses the // github-light palette plus a sidebar gradient; the rest are the Shiki // bundled syntax themes, alphabetically sorted. export const SYNTAX_THEMES = [ "buzz", "buzz-dark", "andromeeda", "aurora-x", "ayu-dark", "catppuccin-frappe", "catppuccin-latte", "catppuccin-macchiato", "catppuccin-mocha", "dark-plus", "dracula", "dracula-soft", "everforest-dark", "everforest-light", "github-dark", "github-dark-default", "github-dark-dimmed", "github-dark-high-contrast", "github-light", "github-light-default", "github-light-high-contrast", "gruvbox-dark-hard", "gruvbox-dark-medium", "gruvbox-dark-soft", "gruvbox-light-hard", "gruvbox-light-medium", "gruvbox-light-soft", "houston", "kanagawa-dragon", "kanagawa-lotus", "kanagawa-wave", "laserwave", "light-plus", "material-theme", "material-theme-darker", "material-theme-lighter", "material-theme-ocean", "material-theme-palenight", "min-dark", "min-light", "monokai", "night-owl", "nord", "one-dark-pro", "one-light", "plastic", "poimandres", "red", "rose-pine", "rose-pine-dawn", "rose-pine-moon", "slack-dark", "slack-ochin", "snazzy-light", "solarized-dark", "solarized-light", "synthwave-84", "tokyo-night", "vesper", "vitesse-black", "vitesse-dark", "vitesse-light", ] as const; export type SyntaxThemeName = (typeof SYNTAX_THEMES)[number]; // Known light themes — used by the theme picker to show sun/moon icons // for themes that haven't been loaded yet. export const LIGHT_THEMES: ReadonlySet = new Set([ "buzz", "catppuccin-latte", "everforest-light", "github-light", "github-light-default", "github-light-high-contrast", "gruvbox-light-hard", "gruvbox-light-medium", "gruvbox-light-soft", "kanagawa-lotus", "light-plus", "material-theme-lighter", "min-light", "one-light", "rose-pine-dawn", "slack-ochin", "snazzy-light", "solarized-light", "vitesse-light", ]); // Static theme imports (Vite needs static strings for tree-shaking) const themeImports: Record< SyntaxThemeName, () => Promise<{ default: ThemeRegistrationRaw }> > = { // Buzz reuses the github-light palette; its gradient is applied separately. buzz: () => import("shiki/themes/github-light.mjs"), // Buzz Dark reuses the github-dark palette; dark gradient applied separately. "buzz-dark": () => import("shiki/themes/github-dark.mjs"), andromeeda: () => import("shiki/themes/andromeeda.mjs"), "aurora-x": () => import("shiki/themes/aurora-x.mjs"), "ayu-dark": () => import("shiki/themes/ayu-dark.mjs"), "catppuccin-frappe": () => import("shiki/themes/catppuccin-frappe.mjs"), "catppuccin-latte": () => import("shiki/themes/catppuccin-latte.mjs"), "catppuccin-macchiato": () => import("shiki/themes/catppuccin-macchiato.mjs"), "catppuccin-mocha": () => import("shiki/themes/catppuccin-mocha.mjs"), "dark-plus": () => import("shiki/themes/dark-plus.mjs"), dracula: () => import("shiki/themes/dracula.mjs"), "dracula-soft": () => import("shiki/themes/dracula-soft.mjs"), "everforest-dark": () => import("shiki/themes/everforest-dark.mjs"), "everforest-light": () => import("shiki/themes/everforest-light.mjs"), "github-dark": () => import("shiki/themes/github-dark.mjs"), "github-dark-default": () => import("shiki/themes/github-dark-default.mjs"), "github-dark-dimmed": () => import("shiki/themes/github-dark-dimmed.mjs"), "github-dark-high-contrast": () => import("shiki/themes/github-dark-high-contrast.mjs"), "github-light": () => import("shiki/themes/github-light.mjs"), "github-light-default": () => import("shiki/themes/github-light-default.mjs"), "github-light-high-contrast": () => import("shiki/themes/github-light-high-contrast.mjs"), "gruvbox-dark-hard": () => import("shiki/themes/gruvbox-dark-hard.mjs"), "gruvbox-dark-medium": () => import("shiki/themes/gruvbox-dark-medium.mjs"), "gruvbox-dark-soft": () => import("shiki/themes/gruvbox-dark-soft.mjs"), "gruvbox-light-hard": () => import("shiki/themes/gruvbox-light-hard.mjs"), "gruvbox-light-medium": () => import("shiki/themes/gruvbox-light-medium.mjs"), "gruvbox-light-soft": () => import("shiki/themes/gruvbox-light-soft.mjs"), houston: () => import("shiki/themes/houston.mjs"), "kanagawa-dragon": () => import("shiki/themes/kanagawa-dragon.mjs"), "kanagawa-lotus": () => import("shiki/themes/kanagawa-lotus.mjs"), "kanagawa-wave": () => import("shiki/themes/kanagawa-wave.mjs"), laserwave: () => import("shiki/themes/laserwave.mjs"), "light-plus": () => import("shiki/themes/light-plus.mjs"), "material-theme": () => import("shiki/themes/material-theme.mjs"), "material-theme-darker": () => import("shiki/themes/material-theme-darker.mjs"), "material-theme-lighter": () => import("shiki/themes/material-theme-lighter.mjs"), "material-theme-ocean": () => import("shiki/themes/material-theme-ocean.mjs"), "material-theme-palenight": () => import("shiki/themes/material-theme-palenight.mjs"), "min-dark": () => import("shiki/themes/min-dark.mjs"), "min-light": () => import("shiki/themes/min-light.mjs"), monokai: () => import("shiki/themes/monokai.mjs"), "night-owl": () => import("shiki/themes/night-owl.mjs"), nord: () => import("shiki/themes/nord.mjs"), "one-dark-pro": () => import("shiki/themes/one-dark-pro.mjs"), "one-light": () => import("shiki/themes/one-light.mjs"), plastic: () => import("shiki/themes/plastic.mjs"), poimandres: () => import("shiki/themes/poimandres.mjs"), red: () => import("shiki/themes/red.mjs"), "rose-pine": () => import("shiki/themes/rose-pine.mjs"), "rose-pine-dawn": () => import("shiki/themes/rose-pine-dawn.mjs"), "rose-pine-moon": () => import("shiki/themes/rose-pine-moon.mjs"), "slack-dark": () => import("shiki/themes/slack-dark.mjs"), "slack-ochin": () => import("shiki/themes/slack-ochin.mjs"), "snazzy-light": () => import("shiki/themes/snazzy-light.mjs"), "solarized-dark": () => import("shiki/themes/solarized-dark.mjs"), "solarized-light": () => import("shiki/themes/solarized-light.mjs"), "synthwave-84": () => import("shiki/themes/synthwave-84.mjs"), "tokyo-night": () => import("shiki/themes/tokyo-night.mjs"), vesper: () => import("shiki/themes/vesper.mjs"), "vitesse-black": () => import("shiki/themes/vitesse-black.mjs"), "vitesse-dark": () => import("shiki/themes/vitesse-dark.mjs"), "vitesse-light": () => import("shiki/themes/vitesse-light.mjs"), }; export function isLightTheme(name: string): boolean { return LIGHT_THEMES.has(name as SyntaxThemeName); } /** * Theme pairs: maps a light theme to its dark counterpart and vice versa. * Used by the "Follow system" feature to auto-switch themes. */ export const THEME_PAIRS: ReadonlyMap = new Map([ // Light → Dark // Buzz is the first-party pair; keep it first so it leads every category. ["buzz", "buzz-dark"], ["catppuccin-latte", "catppuccin-mocha"], ["everforest-light", "everforest-dark"], ["github-light", "github-dark"], ["github-light-default", "github-dark-default"], ["github-light-high-contrast", "github-dark-high-contrast"], ["gruvbox-light-hard", "gruvbox-dark-hard"], ["gruvbox-light-medium", "gruvbox-dark-medium"], ["gruvbox-light-soft", "gruvbox-dark-soft"], ["kanagawa-lotus", "kanagawa-wave"], ["light-plus", "dark-plus"], ["material-theme-lighter", "material-theme"], ["min-light", "min-dark"], ["one-light", "one-dark-pro"], ["rose-pine-dawn", "rose-pine"], ["slack-ochin", "slack-dark"], ["solarized-light", "solarized-dark"], ["vitesse-light", "vitesse-dark"], // Dark → Light (reverse mappings) ["buzz-dark", "buzz"], ["catppuccin-mocha", "catppuccin-latte"], ["everforest-dark", "everforest-light"], ["github-dark", "github-light"], ["github-dark-default", "github-light-default"], ["github-dark-high-contrast", "github-light-high-contrast"], ["gruvbox-dark-hard", "gruvbox-light-hard"], ["gruvbox-dark-medium", "gruvbox-light-medium"], ["gruvbox-dark-soft", "gruvbox-light-soft"], ["kanagawa-wave", "kanagawa-lotus"], ["dark-plus", "light-plus"], ["material-theme", "material-theme-lighter"], ["min-dark", "min-light"], ["one-dark-pro", "one-light"], ["rose-pine", "rose-pine-dawn"], ["slack-dark", "slack-ochin"], ["solarized-dark", "solarized-light"], ["vitesse-dark", "vitesse-light"], ]); /** * Get the counterpart theme for system theme switching. * Returns the paired theme if one exists, or null if the theme has no pair. */ export function getThemePair(name: SyntaxThemeName): SyntaxThemeName | null { return THEME_PAIRS.get(name) ?? null; } /** * Given a user-selected theme and the current system color scheme, * returns the theme that should actually be applied. */ export function resolveSystemTheme( selectedTheme: SyntaxThemeName, systemIsDark: boolean, ): SyntaxThemeName { const selectedIsLight = isLightTheme(selectedTheme); const needsSwitch = (systemIsDark && selectedIsLight) || (!systemIsDark && !selectedIsLight); if (!needsSwitch) return selectedTheme; const pair = getThemePair(selectedTheme); return pair ?? selectedTheme; } // Theme settings type from Shiki interface ThemeSetting { scope?: string | string[]; settings?: { foreground?: string }; } function extractCommentColor( settings: ReadonlyArray | undefined, fallback: string, ): string { if (!settings) return fallback; for (const setting of settings) { if (!setting.scope || !setting.settings?.foreground) continue; const scopes = Array.isArray(setting.scope) ? setting.scope : [setting.scope]; if (scopes.includes("comment")) { return setting.settings.foreground; } } return fallback; } function stripAlpha(color: string): string { if (color.length === 9 && color.startsWith("#")) { return color.slice(0, 7); } return color; } function extractGitColors(colors: Record | undefined): { added: string | null; deleted: string | null; modified: string | null; } { if (!colors) { return { added: null, deleted: null, modified: null }; } const addedKeys = [ "gitDecoration.addedResourceForeground", "editorGutter.addedBackground", "diffEditor.insertedTextBackground", ]; const deletedKeys = [ "gitDecoration.deletedResourceForeground", "editorGutter.deletedBackground", "diffEditor.removedTextBackground", ]; const modifiedKeys = [ "gitDecoration.modifiedResourceForeground", "editorGutter.modifiedBackground", ]; const findColor = (keys: string[]): string | null => { for (const key of keys) { const value = colors[key]; if (value) return stripAlpha(value); } return null; }; return { added: findColor(addedKeys), deleted: findColor(deletedKeys), modified: findColor(modifiedKeys), }; } export interface ThemeInfo { name: string; bg: string; fg: string; comment: string; added: string | null; deleted: string | null; modified: string | null; terminalPalette: TerminalPalette; } export function extractThemeInfo( themeName: string, theme: ThemeRegistrationRaw, ): ThemeInfo { const bg = (theme.colors?.["editor.background"] as string | undefined) || "#1e1e1e"; const fg = (theme.colors?.["editor.foreground"] as string | undefined) || "#d4d4d4"; const gitColors = extractGitColors( theme.colors as Record | undefined, ); return { name: themeName, bg, fg, comment: extractCommentColor( theme.settings as ReadonlyArray | undefined, fg, ), ...gitColors, terminalPalette: extractTerminalPalette(theme), }; } export async function loadThemeData( name: SyntaxThemeName, ): Promise { const loader = themeImports[name]; const { default: theme } = await loader(); return theme; }