From 308e272b10e441f34ddd7c9b9a9f764d601a5aa0 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 4 May 2026 23:25:30 -0700 Subject: [PATCH] feat(ui): 49 themes with matched syntax highlighting + preview mode (#664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): expand theme system to 49 themes with matched syntax highlighting Add 25 new themes sourced from shiki's bundled TextMate themes (Everforest, Nord, Solarized, GitHub, One Dark Pro, Night Owl, Material, Vitesse, Vesper, Poimandres, Ayu Dark, Houston, Laserwave, Andromeeda, Aurora X, Dark+/Light+, Min, One Light, Plastic, Red, Slack, Snazzy Light, Vitesse Black). Each theme was audited against the shiki source JSON to ensure correct color mapping. Wire pierre/diffs syntax highlighting to match the active UI theme via a SHIKI_THEME_MAP (35 of 49 themes get matched syntax token colors in diffs). Add theme preview mode — a compact bottom-docked picker that lets users browse themes while seeing the page update live behind it. Settings panel enhancements: - Syntax highlighting badge on themes with diff color matching - Mode unavailability dimming (dark-only themes dim in light mode) - Plannotator pinned first, rest alphabetical * fix(themes): address review feedback — contrast, stale CSS, syntax mode safety 1. Material light: foreground #90A4AE → #546E7A (Blue Grey 600) for readable UI text (~7:1 contrast on white). Primary-foreground #FAFAFA → #263238 for contrast on teal buttons. 2. DiffHunkPreview: add colorTheme to useEffect deps so unsafeCSS (bg/gutter injection) recomputes when themes change, preventing stale mixed-theme state in mounted tooltips during preview mode. 3. SHIKI_THEME_MAP: make mode-aware with null entries for modes lacking a shiki counterpart. resolveSyntaxTheme() returns undefined when the current mode has no match, so pierre falls back to its defaults rather than applying dark token colors on a light background (fixes Tokyo Night light). * fix(review): self-review cleanup — memo, double-call, escape key - DiffHunkPreview: extract resolveSyntaxTheme to a variable instead of calling it twice in the spread expression - PierreDiffContent memo: compare syntaxTheme by dark/light string values instead of reference equality (resolver returns new objects each call) - Preview mode: add Escape key handler to return to full settings * fix(ui): consume Escape event in theme preview to prevent leaking to page --- .../components/AllFilesDiffView.tsx | 1 + .../components/DiffHunkPreview.tsx | 8 +- .../review-editor/components/DiffViewer.tsx | 5 +- .../review-editor/hooks/usePierreTheme.ts | 50 ++- packages/ui/components/Settings.tsx | 38 ++- packages/ui/components/ThemeTab.tsx | 123 +++++--- packages/ui/theme.css | 25 ++ packages/ui/themes/andromeeda.css | 66 ++++ packages/ui/themes/aurora-x.css | 66 ++++ packages/ui/themes/ayu-dark.css | 66 ++++ packages/ui/themes/dark-plus.css | 66 ++++ packages/ui/themes/everforest-hard.css | 62 ++++ packages/ui/themes/everforest-soft.css | 62 ++++ packages/ui/themes/everforest.css | 62 ++++ packages/ui/themes/github.css | 66 ++++ packages/ui/themes/houston.css | 66 ++++ packages/ui/themes/laserwave.css | 66 ++++ packages/ui/themes/material.css | 62 ++++ packages/ui/themes/min.css | 66 ++++ packages/ui/themes/night-owl.css | 66 ++++ packages/ui/themes/nord.css | 66 ++++ packages/ui/themes/one-dark-pro.css | 66 ++++ packages/ui/themes/one-light.css | 66 ++++ packages/ui/themes/plastic.css | 66 ++++ packages/ui/themes/poimandres.css | 66 ++++ packages/ui/themes/red.css | 66 ++++ packages/ui/themes/slack.css | 66 ++++ packages/ui/themes/snazzy-light.css | 66 ++++ packages/ui/themes/solarized.css | 66 ++++ packages/ui/themes/tokyo-night.css | 4 +- packages/ui/themes/vesper.css | 62 ++++ packages/ui/themes/vitesse-black.css | 66 ++++ packages/ui/themes/vitesse.css | 62 ++++ packages/ui/utils/themeRegistry.ts | 296 +++++++++++++++++- 34 files changed, 2125 insertions(+), 51 deletions(-) create mode 100644 packages/ui/themes/andromeeda.css create mode 100644 packages/ui/themes/aurora-x.css create mode 100644 packages/ui/themes/ayu-dark.css create mode 100644 packages/ui/themes/dark-plus.css create mode 100644 packages/ui/themes/everforest-hard.css create mode 100644 packages/ui/themes/everforest-soft.css create mode 100644 packages/ui/themes/everforest.css create mode 100644 packages/ui/themes/github.css create mode 100644 packages/ui/themes/houston.css create mode 100644 packages/ui/themes/laserwave.css create mode 100644 packages/ui/themes/material.css create mode 100644 packages/ui/themes/min.css create mode 100644 packages/ui/themes/night-owl.css create mode 100644 packages/ui/themes/nord.css create mode 100644 packages/ui/themes/one-dark-pro.css create mode 100644 packages/ui/themes/one-light.css create mode 100644 packages/ui/themes/plastic.css create mode 100644 packages/ui/themes/poimandres.css create mode 100644 packages/ui/themes/red.css create mode 100644 packages/ui/themes/slack.css create mode 100644 packages/ui/themes/snazzy-light.css create mode 100644 packages/ui/themes/solarized.css create mode 100644 packages/ui/themes/vesper.css create mode 100644 packages/ui/themes/vitesse-black.css create mode 100644 packages/ui/themes/vitesse.css diff --git a/packages/review-editor/components/AllFilesDiffView.tsx b/packages/review-editor/components/AllFilesDiffView.tsx index 2139f3ae..2b0d13fd 100644 --- a/packages/review-editor/components/AllFilesDiffView.tsx +++ b/packages/review-editor/components/AllFilesDiffView.tsx @@ -428,6 +428,7 @@ export const AllFilesDiffView: React.FC = ({ options={{ themeType: pierreTheme.type, unsafeCSS: pierreTheme.css, + ...(pierreTheme.syntaxTheme && { theme: pierreTheme.syntaxTheme }), diffStyle, overflow: diffOverflow, diffIndicators, diff --git a/packages/review-editor/components/DiffHunkPreview.tsx b/packages/review-editor/components/DiffHunkPreview.tsx index e2bf95f2..525040e9 100644 --- a/packages/review-editor/components/DiffHunkPreview.tsx +++ b/packages/review-editor/components/DiffHunkPreview.tsx @@ -3,6 +3,7 @@ import { FileDiff } from '@pierre/diffs/react'; import { getSingularPatch } from '@pierre/diffs'; import { useTheme } from '@plannotator/ui/components/ThemeProvider'; import { useReviewState } from '../dock/ReviewStateContext'; +import { resolveSyntaxTheme } from '../hooks/usePierreTheme'; interface DiffHunkPreviewProps { /** Raw diff hunk string (unified diff format). */ @@ -59,7 +60,7 @@ export const DiffHunkPreview: React.FC = ({ maxHeight = 128, className, }) => { - const { resolvedMode } = useTheme(); + const { resolvedMode, colorTheme } = useTheme(); const state = useReviewState(); const [expanded, setExpanded] = useState(false); @@ -97,7 +98,9 @@ export const DiffHunkPreview: React.FC = ({ }); }); return () => cancelAnimationFrame(rafId); - }, [resolvedMode, state.fontFamily, state.fontSize]); + }, [resolvedMode, colorTheme, state.fontFamily, state.fontSize]); + + const syntaxTheme = resolveSyntaxTheme(colorTheme, resolvedMode ?? 'dark'); if (!fileDiff) { return ( @@ -118,6 +121,7 @@ export const DiffHunkPreview: React.FC = ({ options={{ themeType: pierreTheme.type, unsafeCSS: pierreTheme.css, + ...(syntaxTheme && { theme: syntaxTheme }), diffStyle: 'unified', disableLineNumbers: true, overflow: 'wrap', diff --git a/packages/review-editor/components/DiffViewer.tsx b/packages/review-editor/components/DiffViewer.tsx index 31e34d1f..aef0c287 100644 --- a/packages/review-editor/components/DiffViewer.tsx +++ b/packages/review-editor/components/DiffViewer.tsx @@ -27,7 +27,7 @@ import { interface PierreDiffContentProps { filePath: string; fileDiff: ReturnType; - pierreTheme: { type: 'dark' | 'light'; css: string }; + pierreTheme: { type: 'dark' | 'light'; css: string; syntaxTheme?: { dark: string; light: string } }; diffStyle: 'split' | 'unified'; diffOverflow?: 'scroll' | 'wrap'; diffIndicators?: 'bars' | 'classic' | 'none'; @@ -70,6 +70,7 @@ const PierreDiffContent = React.memo(({ options={{ themeType: pierreTheme.type, unsafeCSS: pierreTheme.css, + ...(pierreTheme.syntaxTheme && { theme: pierreTheme.syntaxTheme }), diffStyle, overflow: diffOverflow, diffIndicators, @@ -95,6 +96,8 @@ const PierreDiffContent = React.memo(({ prev.fileDiff === next.fileDiff && prev.pierreTheme.type === next.pierreTheme.type && prev.pierreTheme.css === next.pierreTheme.css && + prev.pierreTheme.syntaxTheme?.dark === next.pierreTheme.syntaxTheme?.dark && + prev.pierreTheme.syntaxTheme?.light === next.pierreTheme.syntaxTheme?.light && prev.diffStyle === next.diffStyle && prev.diffOverflow === next.diffOverflow && prev.diffIndicators === next.diffIndicators && diff --git a/packages/review-editor/hooks/usePierreTheme.ts b/packages/review-editor/hooks/usePierreTheme.ts index ff7aa79a..bfa4cb88 100644 --- a/packages/review-editor/hooks/usePierreTheme.ts +++ b/packages/review-editor/hooks/usePierreTheme.ts @@ -1,9 +1,54 @@ import { useState, useEffect } from 'react'; import { useTheme } from '@plannotator/ui/components/ThemeProvider'; +export const SHIKI_THEME_MAP: Record = { + 'andromeeda': { dark: 'andromeeda', light: null }, + 'aurora-x': { dark: 'aurora-x', light: null }, + 'ayu-dark': { dark: 'ayu-dark', light: null }, + 'catppuccin': { dark: 'catppuccin-mocha', light: 'catppuccin-latte' }, + 'dark-plus': { dark: 'dark-plus', light: 'light-plus' }, + 'dracula': { dark: 'dracula', light: null }, + 'everforest': { dark: 'everforest-dark', light: 'everforest-light' }, + 'everforest-hard': { dark: 'everforest-dark', light: 'everforest-light' }, + 'everforest-soft': { dark: 'everforest-dark', light: 'everforest-light' }, + 'github': { dark: 'github-dark', light: 'github-light' }, + 'gruvbox': { dark: 'gruvbox-dark-medium', light: 'gruvbox-light-medium' }, + 'houston': { dark: 'houston', light: null }, + 'kanagawa-dragon': { dark: 'kanagawa-dragon', light: null }, + 'kanagawa-lotus': { dark: null, light: 'kanagawa-lotus' }, + 'kanagawa-wave': { dark: 'kanagawa-wave', light: null }, + 'laserwave': { dark: 'laserwave', light: null }, + 'material': { dark: 'material-theme', light: 'material-theme-lighter' }, + 'min': { dark: 'min-dark', light: 'min-light' }, + 'monokai-pro': { dark: 'monokai', light: null }, + 'night-owl': { dark: 'night-owl', light: null }, + 'nord': { dark: 'nord', light: null }, + 'one-dark-pro': { dark: 'one-dark-pro', light: null }, + 'one-light': { dark: null, light: 'one-light' }, + 'plastic': { dark: 'plastic', light: null }, + 'poimandres': { dark: 'poimandres', light: null }, + 'red': { dark: 'red', light: null }, + 'rose-pine': { dark: 'rose-pine', light: 'rose-pine-dawn' }, + 'slack': { dark: 'slack-dark', light: 'slack-ochin' }, + 'snazzy-light': { dark: null, light: 'snazzy-light' }, + 'solarized': { dark: 'solarized-dark', light: 'solarized-light' }, + 'synthwave-84': { dark: 'synthwave-84', light: null }, + 'tokyo-night': { dark: 'tokyo-night', light: null }, + 'vesper': { dark: 'vesper', light: null }, + 'vitesse': { dark: 'vitesse-dark', light: 'vitesse-light' }, + 'vitesse-black': { dark: 'vitesse-black', light: null }, +}; + +export function resolveSyntaxTheme(colorTheme: string, mode: 'dark' | 'light'): { dark: string; light: string } | undefined { + const map = SHIKI_THEME_MAP[colorTheme]; + if (!map || !map[mode]) return undefined; + return { dark: map.dark || 'pierre-dark', light: map.light || 'pierre-light' }; +} + export interface PierreTheme { type: 'dark' | 'light'; css: string; + syntaxTheme?: { dark: string; light: string }; } export function usePierreTheme(options?: { fontFamily?: string; fontSize?: string; showFileHeader?: boolean }): PierreTheme { @@ -16,8 +61,8 @@ export function usePierreTheme(options?: { fontFamily?: string; fontSize?: strin const styles = getComputedStyle(document.documentElement); const bg = styles.getPropertyValue('--background').trim(); const fg = styles.getPropertyValue('--foreground').trim(); - if (!bg || !fg) return { type: resolvedMode ?? 'dark', css: '' }; - return { type: resolvedMode ?? 'dark', css: ` + if (!bg || !fg) return { type: resolvedMode ?? 'dark', css: '', syntaxTheme: resolveSyntaxTheme(colorTheme, resolvedMode ?? 'dark') }; + return { type: resolvedMode ?? 'dark', syntaxTheme: resolveSyntaxTheme(colorTheme, resolvedMode ?? 'dark'), css: ` :host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] { --diffs-bg: ${bg} !important; --diffs-fg: ${fg} !important; --diffs-dark-bg: ${bg}; --diffs-light-bg: ${bg}; --diffs-dark: ${fg}; --diffs-light: ${fg}; @@ -43,6 +88,7 @@ export function usePierreTheme(options?: { fontFamily?: string; fontSize?: strin setPierreTheme({ type: resolvedMode, + syntaxTheme: resolveSyntaxTheme(colorTheme, resolvedMode), css: ` :host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] { --diffs-bg: ${bg} !important; diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index a6fe95b9..2b0fde5b 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -582,6 +582,16 @@ const CommentsTab: React.FC = () => { export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { const [showDialog, setShowDialog] = useState(false); + const [themePreview, setThemePreview] = useState(false); + + useEffect(() => { + if (!themePreview) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setThemePreview(false); setShowDialog(true); } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [themePreview]); const [activeTab, setActiveTab] = useState('general'); const [identity, setIdentity] = useState(''); const [obsidian, setObsidian] = useState({ @@ -814,7 +824,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange - {showDialog && createPortal( + {showDialog && !themePreview && createPortal(
setShowDialog(false)} @@ -1090,7 +1100,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange )} {/* === THEME TAB === */} - {activeTab === 'theme' && } + {activeTab === 'theme' && { setShowDialog(false); setThemePreview(true); }} />} {/* === GIT TAB === */} {activeTab === 'git' && mode === 'review' && ( @@ -2071,6 +2081,30 @@ tags: [plan, ...]
, document.body )} + + {themePreview && createPortal( +
+
+
e.stopPropagation()} + > +
+ Theme Preview + +
+
+ +
+
+
, + document.body + )} ); }; diff --git a/packages/ui/components/ThemeTab.tsx b/packages/ui/components/ThemeTab.tsx index 483a8dde..59db7e18 100644 --- a/packages/ui/components/ThemeTab.tsx +++ b/packages/ui/components/ThemeTab.tsx @@ -2,55 +2,94 @@ import React from 'react'; import { useTheme, type Mode } from './ThemeProvider'; import { SunIcon, MoonIcon, SystemIcon } from './icons/themeIcons'; -export const ThemeTab: React.FC = () => { +interface ThemeTabProps { + onPreview?: () => void; + compact?: boolean; +} + +export const ThemeTab: React.FC = ({ onPreview, compact }) => { const { mode, setMode, colorTheme, setColorTheme, availableThemes, resolvedMode } = useTheme(); return ( <> {/* Mode */} -
- +
+ {!compact && }
- {(['dark', 'light', 'system'] as Mode[]).map(m => ( - - ))} + {(['dark', 'light', 'system'] as Mode[]).map(m => { + const isActive = mode === m; + return ( + + ); + })}
+ {compact && ( + + + + + syntax match + + )}
{/* Theme */} -
- -
+
+ {!compact && ( +
+
+ + {onPreview && ( + + )} +
+ + + + + = matched syntax colors in diffs + +
+ )} +
{availableThemes.map(theme => { const isSelected = colorTheme === theme.id; const colors = theme.colors[resolvedMode]; + const modeUnavailable = + (resolvedMode === 'light' && theme.modeSupport === 'dark-only') || + (resolvedMode === 'dark' && theme.modeSupport === 'light-only'); return (