From 0221411c89731d90eea340d8e2446c073ee5fafa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 15:02:40 +0000 Subject: [PATCH] Add GitHub, Monokai and Catppuccin flavor themes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds six themes to the picker: - GitHub Dark / GitHub Light, using Primer's palette and GitHub's own diff swatches so a PR reads the way it does on github.com. These are stronger-contrast than the existing generic dark/light themes, which keep their subtle line fills and One Dark/One Light syntax. - Monokai. - Catppuccin split into its four official flavors β€” Latte, Frappe, Macchiato and Mocha. The old 'catppuccin' value was Mocha; a stored preference naming it now resolves to 'catppuccin-mocha' via resolveTheme() instead of falling back to the default theme. Along the way: - Pull the app-theme -> Prism-style mapping into a shared getSyntaxTheme(), which CodeViewerModal had duplicated. SynthWave '84 now gets the matching Prism style it always had available, and the light flavors get a light syntax theme rather than One Dark. - Give Solarized Light the --shadow-glow it was missing. - Cover the theme table with tests: the picker and VALID_THEMES agree, and every theme defines the full set of CSS variables. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KApMy6Scs1iqBTi5gaRMs4 --- bun_client/frontend/src/App.tsx | 6 +- .../src/components/CodeViewerModal.tsx | 35 +-- .../src/components/review/diff_theme.ts | 64 ++-- bun_client/frontend/src/design/theme.ts | 39 ++- bun_client/frontend/src/index.css | 287 +++++++++++++++++- bun_client/frontend/src/theme.test.ts | 108 +++++++ 6 files changed, 477 insertions(+), 62 deletions(-) create mode 100644 bun_client/frontend/src/theme.test.ts diff --git a/bun_client/frontend/src/App.tsx b/bun_client/frontend/src/App.tsx index f3e261d..9c110e2 100644 --- a/bun_client/frontend/src/App.tsx +++ b/bun_client/frontend/src/App.tsx @@ -9,7 +9,7 @@ import { Modal, Select, Theme, - VALID_THEMES, + resolveTheme, THEME_OPTIONS, ReviewLocation, REVIEW_LOCATION_OPTIONS, @@ -34,8 +34,8 @@ function App() { const [navigating, setNavigating] = useState(false); const isMobile = useIsMobile(); const [theme, setTheme] = useState(() => { - const saved = localStorage.getItem('theme') as Theme; - if (saved && VALID_THEMES.includes(saved)) return saved; + const saved = resolveTheme(localStorage.getItem('theme')); + if (saved) return saved; return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; }); const [reviewLocation, setReviewLocation] = useState(() => { diff --git a/bun_client/frontend/src/components/CodeViewerModal.tsx b/bun_client/frontend/src/components/CodeViewerModal.tsx index dce7a81..2039f24 100644 --- a/bun_client/frontend/src/components/CodeViewerModal.tsx +++ b/bun_client/frontend/src/components/CodeViewerModal.tsx @@ -1,16 +1,6 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; -import { - oneDark, - oneLight, - gruvboxDark, - gruvboxLight, - solarizedlight, - solarizedDarkAtom, - dracula, - nord, - nightOwl, -} from 'react-syntax-highlighter/dist/esm/styles/prism'; import { colors, shadows } from '../design'; +import { getSyntaxTheme } from './review/diff_theme'; import { readFile, listFiles } from '../api'; import type { Theme } from '../design'; import { useLsp } from '../hooks/useLsp'; @@ -472,28 +462,7 @@ export default function CodeViewerModal({ }; // Get theme for syntax highlighting - const syntaxTheme = useMemo(() => { - switch (theme) { - case 'light': - return oneLight; - case 'gruvbox-dark': - return gruvboxDark; - case 'gruvbox-light': - return gruvboxLight; - case 'solarized-light': - return solarizedlight; - case 'solarized-dark': - return solarizedDarkAtom; - case 'dracula': - return dracula; - case 'nord': - return nord; - case 'night-owl': - return nightOwl; - default: - return oneDark; - } - }, [theme]); + const syntaxTheme = useMemo(() => getSyntaxTheme(theme), [theme]); const toggleDir = useCallback( (path: string) => { diff --git a/bun_client/frontend/src/components/review/diff_theme.ts b/bun_client/frontend/src/components/review/diff_theme.ts index b100172..b1aea8b 100644 --- a/bun_client/frontend/src/components/review/diff_theme.ts +++ b/bun_client/frontend/src/components/review/diff_theme.ts @@ -1,42 +1,60 @@ import { oneDark, oneLight, + ghcolors, + vscDarkPlus, gruvboxDark, gruvboxLight, solarizedlight, solarizedDarkAtom, + okaidia, dracula, nord, nightOwl, + synthwave84, } from 'react-syntax-highlighter/dist/esm/styles/prism'; import type { Theme } from '../../design'; +// Prism style that pairs with an app theme. Themes with no close Prism +// equivalent fall back to One Dark (or One Light for the light flavors) so the +// syntax colors never fight the surrounding chrome. +export const getSyntaxTheme = (theme: Theme) => { + switch (theme) { + case 'light': + return oneLight; + case 'github-light': + return ghcolors; + case 'github-dark': + return vscDarkPlus; + case 'gruvbox-dark': + return gruvboxDark; + case 'gruvbox-light': + return gruvboxLight; + case 'solarized-light': + return solarizedlight; + case 'solarized-dark': + return solarizedDarkAtom; + case 'monokai': + return okaidia; + case 'dracula': + return dracula; + case 'nord': + return nord; + case 'night-owl': + return nightOwl; + case 'synthwave-84': + return synthwave84; + case 'catppuccin-latte': + return oneLight; + default: + return oneDark; + } +}; + // Custom Prism theme based on the selected app theme, adjusted so highlighted // lines blend into the diff rows (transparent background, no padding). export const buildDiffTheme = (theme: Theme) => { - const getBaseTheme = () => { - switch (theme) { - case 'light': - return oneLight; - case 'gruvbox-dark': - return gruvboxDark; - case 'gruvbox-light': - return gruvboxLight; - case 'solarized-light': - return solarizedlight; - case 'solarized-dark': - return solarizedDarkAtom; - case 'dracula': - return dracula; - case 'nord': - return nord; - case 'night-owl': - return nightOwl; - default: - return oneDark; - } - }; - const baseTheme = getBaseTheme(); + const baseTheme = getSyntaxTheme(theme); return { ...baseTheme, 'pre[class*="language-"]': { diff --git a/bun_client/frontend/src/design/theme.ts b/bun_client/frontend/src/design/theme.ts index 7684a33..6e97a24 100644 --- a/bun_client/frontend/src/design/theme.ts +++ b/bun_client/frontend/src/design/theme.ts @@ -5,15 +5,21 @@ export const VALID_THEMES = [ 'light', 'dark', + 'github-light', + 'github-dark', 'gruvbox-dark', 'gruvbox-light', 'solarized-light', 'solarized-dark', + 'monokai', 'dracula', 'nord', 'night-owl', 'tokyo-night', - 'catppuccin', + 'catppuccin-latte', + 'catppuccin-frappe', + 'catppuccin-macchiato', + 'catppuccin-mocha', 'everforest', 'rose-pine', 'synthwave-84', @@ -29,20 +35,49 @@ export interface ThemeOption { export const THEME_OPTIONS: ThemeOption[] = [ { value: 'dark', label: 'πŸŒ™ Dark (One Dark)' }, { value: 'light', label: 'β˜€οΈ Light (One Light)' }, + { value: 'github-dark', label: 'πŸ™ GitHub Dark' }, + { value: 'github-light', label: 'πŸ™ GitHub Light' }, { value: 'gruvbox-dark', label: 'πŸ“¦ Gruvbox Dark' }, { value: 'gruvbox-light', label: 'πŸ“¦ Gruvbox Light' }, { value: 'solarized-dark', label: 'β˜€οΈ Solarized Dark' }, { value: 'solarized-light', label: 'β˜€οΈ Solarized Light' }, + { value: 'monokai', label: '🍬 Monokai' }, { value: 'dracula', label: 'πŸ§› Dracula' }, { value: 'nord', label: '❄️ Nord' }, { value: 'night-owl', label: 'πŸ¦‰ Night Owl' }, { value: 'tokyo-night', label: 'πŸŒƒ Tokyo Night' }, - { value: 'catppuccin', label: '🐱 Catppuccin' }, + { value: 'catppuccin-mocha', label: '🐱 Catppuccin Mocha' }, + { value: 'catppuccin-macchiato', label: '🐱 Catppuccin Macchiato' }, + { value: 'catppuccin-frappe', label: '🐱 Catppuccin FrappΓ©' }, + { value: 'catppuccin-latte', label: '🐱 Catppuccin Latte' }, { value: 'everforest', label: '🌲 Everforest' }, { value: 'rose-pine', label: '🌹 Rose Pine' }, { value: 'synthwave-84', label: "πŸ•ΉοΈ SynthWave '84" }, ]; +/** + * Themes that have been renamed. A value persisted by an older client maps to + * its current equivalent instead of silently falling back to the default. + */ +const LEGACY_THEME_ALIASES: Record = { + // 'catppuccin' shipped as Catppuccin's Mocha flavor before the other + // three flavors were added. + catppuccin: 'catppuccin-mocha', +}; + +export const isTheme = (value: unknown): value is Theme => + typeof value === 'string' && (VALID_THEMES as readonly string[]).includes(value); + +/** + * Normalize a stored theme value, returning null when it names no theme this + * build knows about so the caller can fall back to its own default. + */ +export const resolveTheme = (value: string | null): Theme | null => { + if (isTheme(value)) return value; + if (value !== null && value in LEGACY_THEME_ALIASES) return LEGACY_THEME_ALIASES[value]; + return null; +}; + /** * Review Location preferences */ diff --git a/bun_client/frontend/src/index.css b/bun_client/frontend/src/index.css index 4998b70..03a8bb4 100644 --- a/bun_client/frontend/src/index.css +++ b/bun_client/frontend/src/index.css @@ -123,6 +123,104 @@ --overlay-bg: rgba(31, 35, 40, 0.4); } +/* GitHub Dark Theme β€” Primer's own palette, so the diff reads the way the + same PR does on github.com (stronger line fills than the default dark). */ +[data-theme='github-dark'] { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --text-primary: #e6edf3; + --text-secondary: #848d97; + --text-tertiary: #6e7681; + --accent: #2f81f7; + --accent-dim: rgba(47, 129, 247, 0.15); + --border: #30363d; + --success: #238636; + --danger: #da3633; + --warning: #9e6a03; + + --text-success: #3fb950; + --text-danger: #f85149; + --text-warning: #d29922; + --text-merged: #a371f7; + + --bg-success-dim: rgba(46, 160, 67, 0.15); + --bg-danger-dim: rgba(248, 81, 73, 0.15); + --bg-warning-dim: rgba(187, 128, 9, 0.15); + --bg-merged-dim: rgba(163, 113, 247, 0.15); + --bg-info-dim: rgba(47, 129, 247, 0.1); + --bg-info-dim-strong: rgba(47, 129, 247, 0.15); + + --border-success-dim: rgba(63, 185, 80, 0.4); + --border-danger-dim: rgba(248, 81, 73, 0.4); + --border-warning-dim: rgba(210, 153, 34, 0.4); + --border-info-dim: rgba(47, 129, 247, 0.2); + --border-merged-dim: rgba(163, 113, 247, 0.3); + + --diff-add-bg: rgba(46, 160, 67, 0.15); + --diff-add-gutter-bg: rgba(46, 160, 67, 0.3); + --diff-del-bg: rgba(248, 81, 73, 0.1); + --diff-del-gutter-bg: rgba(248, 81, 73, 0.25); + --diff-hunk-bg: rgba(56, 139, 253, 0.15); + --diff-line-no-bg: rgba(255, 255, 255, 0.02); + --diff-line-no-color: #6e7681; + + --shadow-sm: 0 1px 3px rgba(1, 4, 9, 0.5); + --shadow-md: 0 4px 12px rgba(1, 4, 9, 0.5); + --shadow-lg: 0 8px 24px rgba(1, 4, 9, 0.6); + --shadow-glow: 0 0 0 1px rgba(47, 129, 247, 0.2); + --overlay-bg: rgba(1, 4, 9, 0.8); +} + +/* GitHub Light Theme */ +[data-theme='github-light'] { + --bg-primary: #ffffff; + --bg-secondary: #f6f8fa; + --bg-tertiary: #eaeef2; + --text-primary: #1f2328; + --text-secondary: #656d76; + --text-tertiary: #8c959f; + --accent: #0969da; + --accent-dim: rgba(9, 105, 218, 0.1); + --border: #d1d9e0; + --success: #1f883d; + --danger: #cf222e; + --warning: #9a6700; + + --text-success: #1a7f37; + --text-danger: #cf222e; + --text-warning: #9a6700; + --text-merged: #8250df; + + --bg-success-dim: rgba(31, 136, 61, 0.1); + --bg-danger-dim: rgba(207, 34, 46, 0.1); + --bg-warning-dim: rgba(154, 103, 0, 0.1); + --bg-merged-dim: rgba(130, 80, 223, 0.1); + --bg-info-dim: rgba(9, 105, 218, 0.05); + --bg-info-dim-strong: rgba(9, 105, 218, 0.1); + + --border-success-dim: rgba(31, 136, 61, 0.2); + --border-danger-dim: rgba(207, 34, 46, 0.2); + --border-warning-dim: rgba(154, 103, 0, 0.2); + --border-info-dim: rgba(9, 105, 218, 0.2); + --border-merged-dim: rgba(130, 80, 223, 0.2); + + /* GitHub's own diff swatches */ + --diff-add-bg: #e6ffec; + --diff-add-gutter-bg: #abf2bc; + --diff-del-bg: #ffebe9; + --diff-del-gutter-bg: #ffc1c0; + --diff-hunk-bg: #ddf4ff; + --diff-line-no-bg: rgba(0, 0, 0, 0.025); + --diff-line-no-color: #59636e; + + --shadow-sm: 0 1px 2px rgba(31, 35, 40, 0.075); + --shadow-md: 0 3px 6px rgba(140, 149, 159, 0.15); + --shadow-lg: 0 8px 24px rgba(140, 149, 159, 0.2); + --shadow-glow: 0 0 0 1px rgba(9, 105, 218, 0.2); + --overlay-bg: rgba(31, 35, 40, 0.4); +} + /* Gruvbox Dark Theme */ [data-theme='gruvbox-dark'] { --bg-primary: #282828; @@ -309,9 +407,56 @@ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1); --shadow-md: 0 3px 6px rgba(0, 0, 0, 0.1); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15); + --shadow-glow: 0 0 0 1px rgba(38, 139, 210, 0.2); --overlay-bg: rgba(253, 246, 227, 0.4); } +/* Monokai Theme */ +[data-theme='monokai'] { + --bg-primary: #272822; + --bg-secondary: #1e1f1c; + --bg-tertiary: #3e3d32; + --text-primary: #f8f8f2; + --text-secondary: #a59f85; + --text-tertiary: #75715e; + --accent: #66d9ef; + --accent-dim: rgba(102, 217, 239, 0.15); + --border: #49483e; + --success: #a6e22e; + --danger: #f92672; + --warning: #e6db74; + + --text-success: #a6e22e; + --text-danger: #f92672; + --text-warning: #e6db74; + --text-merged: #ae81ff; + + --bg-success-dim: rgba(166, 226, 46, 0.15); + --bg-danger-dim: rgba(249, 38, 114, 0.15); + --bg-warning-dim: rgba(230, 219, 116, 0.15); + --bg-merged-dim: rgba(174, 129, 255, 0.15); + --bg-info-dim: rgba(102, 217, 239, 0.1); + --bg-info-dim-strong: rgba(102, 217, 239, 0.15); + + --border-success-dim: rgba(166, 226, 46, 0.4); + --border-danger-dim: rgba(249, 38, 114, 0.4); + --border-warning-dim: rgba(230, 219, 116, 0.4); + --border-info-dim: rgba(102, 217, 239, 0.2); + --border-merged-dim: rgba(174, 129, 255, 0.3); + + --diff-add-bg: rgba(166, 226, 46, 0.12); + --diff-add-gutter-bg: rgba(166, 226, 46, 0.2); + --diff-del-bg: rgba(249, 38, 114, 0.12); + --diff-del-gutter-bg: rgba(249, 38, 114, 0.2); + --diff-hunk-bg: rgba(102, 217, 239, 0.1); + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6); + --shadow-glow: 0 0 0 1px rgba(102, 217, 239, 0.2); + --overlay-bg: rgba(0, 0, 0, 0.8); +} + /* Dracula Theme */ [data-theme='dracula'] { --bg-primary: #282a36; @@ -496,8 +641,148 @@ --overlay-bg: rgba(0, 0, 0, 0.8); } +/* Catppuccin Latte Theme (light flavor) */ +[data-theme='catppuccin-latte'] { + --bg-primary: #eff1f5; + --bg-secondary: #e6e9ef; + --bg-tertiary: #dce0e8; + --text-primary: #4c4f69; + --text-secondary: #6c6f85; + --text-tertiary: #8c8fa1; + --accent: #1e66f5; + --accent-dim: rgba(30, 102, 245, 0.1); + --border: #ccd0da; + --success: #40a02b; + --danger: #d20f39; + --warning: #df8e1d; + + --text-success: #40a02b; + --text-danger: #d20f39; + --text-warning: #df8e1d; + --text-merged: #8839ef; + + --bg-success-dim: rgba(64, 160, 43, 0.1); + --bg-danger-dim: rgba(210, 15, 57, 0.1); + --bg-warning-dim: rgba(223, 142, 29, 0.1); + --bg-merged-dim: rgba(136, 57, 239, 0.1); + --bg-info-dim: rgba(30, 102, 245, 0.05); + --bg-info-dim-strong: rgba(30, 102, 245, 0.1); + + --border-success-dim: rgba(64, 160, 43, 0.2); + --border-danger-dim: rgba(210, 15, 57, 0.2); + --border-warning-dim: rgba(223, 142, 29, 0.2); + --border-info-dim: rgba(30, 102, 245, 0.2); + --border-merged-dim: rgba(136, 57, 239, 0.2); + + --diff-add-bg: #cfe2d1; + --diff-add-gutter-bg: #b7d7b4; + --diff-del-bg: #eac8d3; + --diff-del-gutter-bg: #e6a9b9; + --diff-hunk-bg: #d2def5; + --diff-line-no-bg: rgba(0, 0, 0, 0.025); + --diff-line-no-color: #8c8fa1; + + --shadow-sm: 0 1px 2px rgba(76, 79, 105, 0.1); + --shadow-md: 0 3px 6px rgba(76, 79, 105, 0.1); + --shadow-lg: 0 8px 24px rgba(76, 79, 105, 0.15); + --shadow-glow: 0 0 0 1px rgba(30, 102, 245, 0.2); + --overlay-bg: rgba(76, 79, 105, 0.4); +} + +/* Catppuccin FrappΓ© Theme */ +[data-theme='catppuccin-frappe'] { + --bg-primary: #303446; + --bg-secondary: #292c3c; + --bg-tertiary: #414559; + --text-primary: #c6d0f5; + --text-secondary: #a5adce; + --text-tertiary: #737994; + --accent: #8caaee; + --accent-dim: rgba(140, 170, 238, 0.15); + --border: #51576d; + --success: #a6d189; + --danger: #e78284; + --warning: #e5c890; + + --text-success: #a6d189; + --text-danger: #e78284; + --text-warning: #e5c890; + --text-merged: #ca9ee6; + + --bg-success-dim: rgba(166, 209, 137, 0.15); + --bg-danger-dim: rgba(231, 130, 132, 0.15); + --bg-warning-dim: rgba(229, 200, 144, 0.15); + --bg-merged-dim: rgba(202, 158, 230, 0.15); + --bg-info-dim: rgba(140, 170, 238, 0.1); + --bg-info-dim-strong: rgba(140, 170, 238, 0.15); + + --border-success-dim: rgba(166, 209, 137, 0.4); + --border-danger-dim: rgba(231, 130, 132, 0.4); + --border-warning-dim: rgba(229, 200, 144, 0.4); + --border-info-dim: rgba(140, 170, 238, 0.2); + --border-merged-dim: rgba(202, 158, 230, 0.3); + + --diff-add-bg: rgba(166, 209, 137, 0.12); + --diff-add-gutter-bg: rgba(166, 209, 137, 0.2); + --diff-del-bg: rgba(231, 130, 132, 0.12); + --diff-del-gutter-bg: rgba(231, 130, 132, 0.2); + --diff-hunk-bg: rgba(140, 170, 238, 0.1); + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5); + --shadow-glow: 0 0 0 1px rgba(140, 170, 238, 0.2); + --overlay-bg: rgba(0, 0, 0, 0.7); +} + +/* Catppuccin Macchiato Theme */ +[data-theme='catppuccin-macchiato'] { + --bg-primary: #24273a; + --bg-secondary: #1e2030; + --bg-tertiary: #363a4f; + --text-primary: #cad3f5; + --text-secondary: #a5adcb; + --text-tertiary: #6e738d; + --accent: #8aadf4; + --accent-dim: rgba(138, 173, 244, 0.15); + --border: #494d64; + --success: #a6da95; + --danger: #ed8796; + --warning: #eed49f; + + --text-success: #a6da95; + --text-danger: #ed8796; + --text-warning: #eed49f; + --text-merged: #c6a0f6; + + --bg-success-dim: rgba(166, 218, 149, 0.15); + --bg-danger-dim: rgba(237, 135, 150, 0.15); + --bg-warning-dim: rgba(238, 212, 159, 0.15); + --bg-merged-dim: rgba(198, 160, 246, 0.15); + --bg-info-dim: rgba(138, 173, 244, 0.1); + --bg-info-dim-strong: rgba(138, 173, 244, 0.15); + + --border-success-dim: rgba(166, 218, 149, 0.4); + --border-danger-dim: rgba(237, 135, 150, 0.4); + --border-warning-dim: rgba(238, 212, 159, 0.4); + --border-info-dim: rgba(138, 173, 244, 0.2); + --border-merged-dim: rgba(198, 160, 246, 0.3); + + --diff-add-bg: rgba(166, 218, 149, 0.12); + --diff-add-gutter-bg: rgba(166, 218, 149, 0.2); + --diff-del-bg: rgba(237, 135, 150, 0.12); + --diff-del-gutter-bg: rgba(237, 135, 150, 0.2); + --diff-hunk-bg: rgba(138, 173, 244, 0.1); + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6); + --shadow-glow: 0 0 0 1px rgba(138, 173, 244, 0.2); + --overlay-bg: rgba(0, 0, 0, 0.8); +} + /* Catppuccin Mocha Theme */ -[data-theme='catppuccin'] { +[data-theme='catppuccin-mocha'] { --bg-primary: #1e1e2e; --bg-secondary: #181825; --bg-tertiary: #313244; diff --git a/bun_client/frontend/src/theme.test.ts b/bun_client/frontend/src/theme.test.ts new file mode 100644 index 0000000..71b58ba --- /dev/null +++ b/bun_client/frontend/src/theme.test.ts @@ -0,0 +1,108 @@ +import { expect, test, describe } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { VALID_THEMES, THEME_OPTIONS, isTheme, resolveTheme } from './design/theme'; + +const css = readFileSync(new URL('./index.css', import.meta.url), 'utf8'); + +// Every var the dark theme defines is one the app expects to resolve under any +// theme, so a new theme block that forgets one silently inherits the default. +const REQUIRED_VARS = [ + '--bg-primary', + '--bg-secondary', + '--bg-tertiary', + '--text-primary', + '--text-secondary', + '--text-tertiary', + '--accent', + '--accent-dim', + '--border', + '--success', + '--danger', + '--warning', + '--text-success', + '--text-danger', + '--text-warning', + '--text-merged', + '--bg-success-dim', + '--bg-danger-dim', + '--bg-warning-dim', + '--bg-merged-dim', + '--bg-info-dim', + '--bg-info-dim-strong', + '--border-success-dim', + '--border-danger-dim', + '--border-warning-dim', + '--border-info-dim', + '--border-merged-dim', + '--diff-add-bg', + '--diff-add-gutter-bg', + '--diff-del-bg', + '--diff-del-gutter-bg', + '--diff-hunk-bg', + '--shadow-sm', + '--shadow-md', + '--shadow-lg', + '--shadow-glow', + '--overlay-bg', +]; + +// Body of the `[data-theme='name'] { ... }` rule, or null when absent. The +// default 'dark' theme shares its block with `:root`. +const themeBlock = (name: string): string | null => { + const selector = + name === 'dark' ? `:root,\\s*\\[data-theme='dark'\\]` : `\\[data-theme='${name}'\\]`; + const match = css.match(new RegExp(`${selector}\\s*\\{([^}]*)\\}`)); + return match ? match[1] : null; +}; + +describe('theme definitions', () => { + test('every theme in the picker is a valid theme', () => { + for (const option of THEME_OPTIONS) { + expect(VALID_THEMES).toContain(option.value); + } + }); + + test('every valid theme is offered in the picker', () => { + const offered = THEME_OPTIONS.map(o => o.value); + for (const theme of VALID_THEMES) { + expect(offered).toContain(theme); + } + }); + + test('the picker has no duplicate entries', () => { + const offered = THEME_OPTIONS.map(o => o.value); + expect(new Set(offered).size).toBe(offered.length); + }); + + test('every theme has a CSS block defining all required variables', () => { + for (const theme of VALID_THEMES) { + const block = themeBlock(theme); + expect(block, `missing [data-theme='${theme}'] block in index.css`).not.toBeNull(); + for (const cssVar of REQUIRED_VARS) { + expect(block, `${theme} is missing ${cssVar}`).toContain(`${cssVar}:`); + } + } + }); +}); + +describe('resolveTheme', () => { + test('accepts known themes', () => { + expect(resolveTheme('tokyo-night')).toBe('tokyo-night'); + expect(resolveTheme('github-dark')).toBe('github-dark'); + }); + + test('migrates the pre-flavor catppuccin value to mocha', () => { + expect(resolveTheme('catppuccin')).toBe('catppuccin-mocha'); + }); + + test('rejects unknown and missing values so callers can pick a default', () => { + expect(resolveTheme('not-a-theme')).toBeNull(); + expect(resolveTheme(null)).toBeNull(); + }); + + test('isTheme narrows only known values', () => { + expect(isTheme('monokai')).toBe(true); + expect(isTheme('catppuccin')).toBe(false); + expect(isTheme(undefined)).toBe(false); + }); +});