Skip to content

Commit cb9edd9

Browse files
committed
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
1 parent e0fb690 commit cb9edd9

34 files changed

Lines changed: 2106 additions & 50 deletions

packages/review-editor/components/AllFilesDiffView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@ export const AllFilesDiffView: React.FC<AllFilesDiffViewProps> = ({
428428
options={{
429429
themeType: pierreTheme.type,
430430
unsafeCSS: pierreTheme.css,
431+
...(pierreTheme.syntaxTheme && { theme: pierreTheme.syntaxTheme }),
431432
diffStyle,
432433
overflow: diffOverflow,
433434
diffIndicators,

packages/review-editor/components/DiffHunkPreview.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { FileDiff } from '@pierre/diffs/react';
33
import { getSingularPatch } from '@pierre/diffs';
44
import { useTheme } from '@plannotator/ui/components/ThemeProvider';
55
import { useReviewState } from '../dock/ReviewStateContext';
6+
import { SHIKI_THEME_MAP } from '../hooks/usePierreTheme';
67

78
interface DiffHunkPreviewProps {
89
/** Raw diff hunk string (unified diff format). */
@@ -59,7 +60,7 @@ export const DiffHunkPreview: React.FC<DiffHunkPreviewProps> = ({
5960
maxHeight = 128,
6061
className,
6162
}) => {
62-
const { resolvedMode } = useTheme();
63+
const { resolvedMode, colorTheme } = useTheme();
6364
const state = useReviewState();
6465
const [expanded, setExpanded] = useState(false);
6566

@@ -118,6 +119,7 @@ export const DiffHunkPreview: React.FC<DiffHunkPreviewProps> = ({
118119
options={{
119120
themeType: pierreTheme.type,
120121
unsafeCSS: pierreTheme.css,
122+
...(SHIKI_THEME_MAP[colorTheme] && { theme: SHIKI_THEME_MAP[colorTheme] }),
121123
diffStyle: 'unified',
122124
disableLineNumbers: true,
123125
overflow: 'wrap',

packages/review-editor/components/DiffViewer.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
interface PierreDiffContentProps {
2828
filePath: string;
2929
fileDiff: ReturnType<typeof getSingularPatch>;
30-
pierreTheme: { type: 'dark' | 'light'; css: string };
30+
pierreTheme: { type: 'dark' | 'light'; css: string; syntaxTheme?: { dark: string; light: string } };
3131
diffStyle: 'split' | 'unified';
3232
diffOverflow?: 'scroll' | 'wrap';
3333
diffIndicators?: 'bars' | 'classic' | 'none';
@@ -70,6 +70,7 @@ const PierreDiffContent = React.memo(({
7070
options={{
7171
themeType: pierreTheme.type,
7272
unsafeCSS: pierreTheme.css,
73+
...(pierreTheme.syntaxTheme && { theme: pierreTheme.syntaxTheme }),
7374
diffStyle,
7475
overflow: diffOverflow,
7576
diffIndicators,
@@ -95,6 +96,7 @@ const PierreDiffContent = React.memo(({
9596
prev.fileDiff === next.fileDiff &&
9697
prev.pierreTheme.type === next.pierreTheme.type &&
9798
prev.pierreTheme.css === next.pierreTheme.css &&
99+
prev.pierreTheme.syntaxTheme === next.pierreTheme.syntaxTheme &&
98100
prev.diffStyle === next.diffStyle &&
99101
prev.diffOverflow === next.diffOverflow &&
100102
prev.diffIndicators === next.diffIndicators &&

packages/review-editor/hooks/usePierreTheme.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,48 @@
11
import { useState, useEffect } from 'react';
22
import { useTheme } from '@plannotator/ui/components/ThemeProvider';
33

4+
export const SHIKI_THEME_MAP: Record<string, { dark: string; light: string }> = {
5+
'andromeeda': { dark: 'andromeeda', light: 'andromeeda' },
6+
'aurora-x': { dark: 'aurora-x', light: 'aurora-x' },
7+
'ayu-dark': { dark: 'ayu-dark', light: 'ayu-dark' },
8+
'catppuccin': { dark: 'catppuccin-mocha', light: 'catppuccin-latte' },
9+
'dark-plus': { dark: 'dark-plus', light: 'light-plus' },
10+
'dracula': { dark: 'dracula', light: 'dracula' },
11+
'everforest': { dark: 'everforest-dark', light: 'everforest-light' },
12+
'everforest-hard': { dark: 'everforest-dark', light: 'everforest-light' },
13+
'everforest-soft': { dark: 'everforest-dark', light: 'everforest-light' },
14+
'github': { dark: 'github-dark', light: 'github-light' },
15+
'gruvbox': { dark: 'gruvbox-dark-medium', light: 'gruvbox-light-medium' },
16+
'houston': { dark: 'houston', light: 'houston' },
17+
'kanagawa-dragon': { dark: 'kanagawa-dragon', light: 'kanagawa-dragon' },
18+
'kanagawa-lotus': { dark: 'kanagawa-lotus', light: 'kanagawa-lotus' },
19+
'kanagawa-wave': { dark: 'kanagawa-wave', light: 'kanagawa-wave' },
20+
'laserwave': { dark: 'laserwave', light: 'laserwave' },
21+
'material': { dark: 'material-theme', light: 'material-theme-lighter' },
22+
'min': { dark: 'min-dark', light: 'min-light' },
23+
'monokai-pro': { dark: 'monokai', light: 'monokai' },
24+
'night-owl': { dark: 'night-owl', light: 'night-owl' },
25+
'nord': { dark: 'nord', light: 'nord' },
26+
'one-dark-pro': { dark: 'one-dark-pro', light: 'one-dark-pro' },
27+
'one-light': { dark: 'one-light', light: 'one-light' },
28+
'plastic': { dark: 'plastic', light: 'plastic' },
29+
'poimandres': { dark: 'poimandres', light: 'poimandres' },
30+
'red': { dark: 'red', light: 'red' },
31+
'rose-pine': { dark: 'rose-pine', light: 'rose-pine-dawn' },
32+
'slack': { dark: 'slack-dark', light: 'slack-ochin' },
33+
'snazzy-light': { dark: 'snazzy-light', light: 'snazzy-light' },
34+
'solarized': { dark: 'solarized-dark', light: 'solarized-light' },
35+
'synthwave-84': { dark: 'synthwave-84', light: 'synthwave-84' },
36+
'tokyo-night': { dark: 'tokyo-night', light: 'tokyo-night' },
37+
'vesper': { dark: 'vesper', light: 'vesper' },
38+
'vitesse': { dark: 'vitesse-dark', light: 'vitesse-light' },
39+
'vitesse-black': { dark: 'vitesse-black', light: 'vitesse-black' },
40+
};
41+
442
export interface PierreTheme {
543
type: 'dark' | 'light';
644
css: string;
45+
syntaxTheme?: { dark: string; light: string };
746
}
847

948
export function usePierreTheme(options?: { fontFamily?: string; fontSize?: string; showFileHeader?: boolean }): PierreTheme {
@@ -16,8 +55,8 @@ export function usePierreTheme(options?: { fontFamily?: string; fontSize?: strin
1655
const styles = getComputedStyle(document.documentElement);
1756
const bg = styles.getPropertyValue('--background').trim();
1857
const fg = styles.getPropertyValue('--foreground').trim();
19-
if (!bg || !fg) return { type: resolvedMode ?? 'dark', css: '' };
20-
return { type: resolvedMode ?? 'dark', css: `
58+
if (!bg || !fg) return { type: resolvedMode ?? 'dark', css: '', syntaxTheme: SHIKI_THEME_MAP[colorTheme] };
59+
return { type: resolvedMode ?? 'dark', syntaxTheme: SHIKI_THEME_MAP[colorTheme], css: `
2160
:host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] {
2261
--diffs-bg: ${bg} !important; --diffs-fg: ${fg} !important;
2362
--diffs-dark-bg: ${bg}; --diffs-light-bg: ${bg}; --diffs-dark: ${fg}; --diffs-light: ${fg};
@@ -43,6 +82,7 @@ export function usePierreTheme(options?: { fontFamily?: string; fontSize?: strin
4382

4483
setPierreTheme({
4584
type: resolvedMode,
85+
syntaxTheme: SHIKI_THEME_MAP[colorTheme],
4686
css: `
4787
:host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] {
4888
--diffs-bg: ${bg} !important;

packages/ui/components/Settings.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,7 @@ const CommentsTab: React.FC = () => {
582582

583583
export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => {
584584
const [showDialog, setShowDialog] = useState(false);
585+
const [themePreview, setThemePreview] = useState(false);
585586
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
586587
const [identity, setIdentity] = useState('');
587588
const [obsidian, setObsidian] = useState<ObsidianSettings>({
@@ -814,7 +815,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
814815
</svg>
815816
</button>
816817

817-
{showDialog && createPortal(
818+
{showDialog && !themePreview && createPortal(
818819
<div
819820
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4"
820821
onClick={() => setShowDialog(false)}
@@ -1090,7 +1091,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
10901091
)}
10911092

10921093
{/* === THEME TAB === */}
1093-
{activeTab === 'theme' && <ThemeTab />}
1094+
{activeTab === 'theme' && <ThemeTab onPreview={() => { setShowDialog(false); setThemePreview(true); }} />}
10941095

10951096
{/* === GIT TAB === */}
10961097
{activeTab === 'git' && mode === 'review' && (
@@ -2071,6 +2072,30 @@ tags: [plan, ...]
20712072
</div>,
20722073
document.body
20732074
)}
2075+
2076+
{themePreview && createPortal(
2077+
<div className="fixed inset-0 z-[100] flex flex-col pointer-events-none">
2078+
<div className="flex-1" />
2079+
<div
2080+
className="pointer-events-auto w-full bg-card border-t-2 border-primary/30 shadow-[0_-4px_20px_rgba(0,0,0,0.4)] flex flex-col max-h-[35vh] overflow-hidden"
2081+
onClick={e => e.stopPropagation()}
2082+
>
2083+
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border flex-shrink-0">
2084+
<span className="text-xs font-medium text-muted-foreground">Theme Preview</span>
2085+
<button
2086+
onClick={() => { setThemePreview(false); setShowDialog(true); }}
2087+
className="px-2.5 py-1 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
2088+
>
2089+
Done
2090+
</button>
2091+
</div>
2092+
<div className="p-3 overflow-y-auto flex-1 min-h-0">
2093+
<ThemeTab compact />
2094+
</div>
2095+
</div>
2096+
</div>,
2097+
document.body
2098+
)}
20742099
</>
20752100
);
20762101
};

packages/ui/components/ThemeTab.tsx

Lines changed: 86 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,65 +2,114 @@ import React from 'react';
22
import { useTheme, type Mode } from './ThemeProvider';
33
import { SunIcon, MoonIcon, SystemIcon } from './icons/themeIcons';
44

5-
export const ThemeTab: React.FC = () => {
5+
interface ThemeTabProps {
6+
onPreview?: () => void;
7+
compact?: boolean;
8+
}
9+
10+
export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
611
const { mode, setMode, colorTheme, setColorTheme, availableThemes, resolvedMode } = useTheme();
712

813
return (
914
<>
1015
{/* Mode */}
11-
<div className="space-y-2">
12-
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Mode</label>
16+
<div className={compact ? 'flex items-center gap-3 mb-2' : 'space-y-2'}>
17+
{!compact && <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Mode</label>}
1318
<div className="flex gap-1">
14-
{(['dark', 'light', 'system'] as Mode[]).map(m => (
15-
<button
16-
key={m}
17-
onClick={() => setMode(m)}
18-
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
19-
mode === m
20-
? 'bg-primary text-primary-foreground'
21-
: 'bg-muted text-muted-foreground hover:text-foreground'
22-
}`}
23-
>
24-
{m === 'dark' && (
25-
<span className="flex items-center gap-1.5">
26-
<MoonIcon className="w-3 h-3" />
27-
Dark
28-
</span>
29-
)}
30-
{m === 'light' && (
31-
<span className="flex items-center gap-1.5">
32-
<SunIcon className="w-3 h-3" />
33-
Light
34-
</span>
35-
)}
36-
{m === 'system' && (
37-
<span className="flex items-center gap-1.5">
38-
<SystemIcon className="w-3 h-3" />
39-
System
40-
</span>
41-
)}
42-
</button>
43-
))}
19+
{(['dark', 'light', 'system'] as Mode[]).map(m => {
20+
const isActive = mode === m;
21+
return (
22+
<button
23+
key={m}
24+
onClick={() => setMode(m)}
25+
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
26+
isActive
27+
? 'bg-primary text-primary-foreground'
28+
: 'bg-muted text-muted-foreground hover:text-foreground'
29+
}`}
30+
>
31+
{m === 'dark' && (
32+
<span className="flex items-center gap-1.5">
33+
<MoonIcon className="w-3 h-3" />
34+
Dark
35+
</span>
36+
)}
37+
{m === 'light' && (
38+
<span className="flex items-center gap-1.5">
39+
<SunIcon className="w-3 h-3" />
40+
Light
41+
</span>
42+
)}
43+
{m === 'system' && (
44+
<span className="flex items-center gap-1.5">
45+
<SystemIcon className="w-3 h-3" />
46+
System
47+
</span>
48+
)}
49+
</button>
50+
);
51+
})}
4452
</div>
53+
{compact && (
54+
<span className="text-[10px] text-muted-foreground/60 ml-auto flex items-center gap-1">
55+
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
56+
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
57+
</svg>
58+
syntax match
59+
</span>
60+
)}
4561
</div>
4662

4763
{/* Theme */}
48-
<div className="space-y-2">
49-
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Theme</label>
50-
<div className="grid grid-cols-3 gap-2 overflow-y-auto pr-1">
64+
<div className={compact ? '' : 'space-y-2'}>
65+
{!compact && (
66+
<div className="flex items-center justify-between">
67+
<div className="flex items-center gap-2">
68+
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Theme</label>
69+
{onPreview && (
70+
<button
71+
onClick={onPreview}
72+
className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 text-primary border border-primary/20 hover:bg-primary/20 hover:border-primary/40 transition-colors"
73+
>
74+
Launch Preview Mode
75+
</button>
76+
)}
77+
</div>
78+
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
79+
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
80+
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
81+
</svg>
82+
= matched syntax colors in diffs
83+
</span>
84+
</div>
85+
)}
86+
<div className={`grid gap-2 overflow-y-auto pr-1 ${compact ? 'grid-cols-4' : 'grid-cols-3'}`}>
5187
{availableThemes.map(theme => {
5288
const isSelected = colorTheme === theme.id;
5389
const colors = theme.colors[resolvedMode];
90+
const modeUnavailable =
91+
(resolvedMode === 'light' && theme.modeSupport === 'dark-only') ||
92+
(resolvedMode === 'dark' && theme.modeSupport === 'light-only');
5493
return (
5594
<button
5695
key={theme.id}
5796
onClick={() => setColorTheme(theme.id)}
5897
className={`relative p-2 rounded-md border text-left transition-colors ${
5998
isSelected
6099
? 'border-primary bg-primary/5'
61-
: 'border-border hover:border-muted-foreground/30 hover:bg-muted/30'
100+
: modeUnavailable
101+
? 'border-border/50 opacity-45'
102+
: 'border-border hover:border-muted-foreground/30 hover:bg-muted/30'
62103
}`}
63104
>
105+
{/* Syntax highlighting badge */}
106+
{theme.syntaxHighlighting && (
107+
<div className="absolute top-1 right-1" title="Matched syntax highlighting in diffs">
108+
<svg className="w-2.5 h-2.5 text-muted-foreground/50" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
109+
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
110+
</svg>
111+
</div>
112+
)}
64113
{/* Color swatches */}
65114
<div className="flex gap-1 mb-1.5">
66115
{[colors.primary, colors.secondary, colors.accent, colors.background, colors.foreground].map((color, i) => (

packages/ui/theme.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,31 @@
2525
@import "./themes/cursor.css";
2626
@import "./themes/cursor-hc.css";
2727
@import "./themes/cursor-midnight.css";
28+
@import "./themes/everforest.css";
29+
@import "./themes/everforest-hard.css";
30+
@import "./themes/everforest-soft.css";
31+
@import "./themes/nord.css";
32+
@import "./themes/solarized.css";
33+
@import "./themes/github.css";
34+
@import "./themes/one-dark-pro.css";
35+
@import "./themes/night-owl.css";
36+
@import "./themes/ayu-dark.css";
37+
@import "./themes/poimandres.css";
38+
@import "./themes/material.css";
39+
@import "./themes/vitesse.css";
40+
@import "./themes/vesper.css";
41+
@import "./themes/andromeeda.css";
42+
@import "./themes/aurora-x.css";
43+
@import "./themes/dark-plus.css";
44+
@import "./themes/houston.css";
45+
@import "./themes/laserwave.css";
46+
@import "./themes/min.css";
47+
@import "./themes/one-light.css";
48+
@import "./themes/plastic.css";
49+
@import "./themes/red.css";
50+
@import "./themes/slack.css";
51+
@import "./themes/snazzy-light.css";
52+
@import "./themes/vitesse-black.css";
2853
@import "./print.css";
2954

3055
/* Tailwind bridge — maps CSS vars to utility classes */

0 commit comments

Comments
 (0)