Files
backnotprop__plannotator/packages/review-editor/components/DiffHunkPreview.tsx
T
Michael Ramos 308e272b10 feat(ui): 49 themes with matched syntax highlighting + preview mode (#664)
* 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
2026-05-04 23:25:30 -07:00

142 lines
5.0 KiB
TypeScript

import React, { useMemo, useState, useEffect } from 'react';
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). */
hunk: string;
/** Max height in pixels before "Show more" toggle. Default 128. */
maxHeight?: number;
className?: string;
}
/**
* Build the unsafeCSS string for @pierre/diffs by reading computed CSS variables.
* Called synchronously so the first render is already themed (no flash on tooltip open).
*/
function buildPierreCSS(mode: 'dark' | 'light', fontFamily: string, fontSize: string): string {
try {
const styles = getComputedStyle(document.documentElement);
const bg = styles.getPropertyValue('--background').trim();
const fg = styles.getPropertyValue('--foreground').trim();
if (!bg || !fg) return '';
const fontCSS = (fontFamily || fontSize) ? `
pre, code, [data-line-content], [data-column-number] {
${fontFamily ? `font-family: '${fontFamily}', monospace !important;` : ''}
${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''}
}` : '';
return `
: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};
}
pre, code { background-color: ${bg} !important; }
[data-column-number] { background-color: ${bg} !important; }
[data-file-info] { display: none !important; }
[data-diffs-header] { display: none !important; }
${fontCSS}
`;
} catch {
return '';
}
}
/**
* Renders a small inline diff hunk using @pierre/diffs.
* Compact, read-only, no file header. Shares theme + font settings
* with the main DiffViewer via the same unsafeCSS injection pattern.
*/
export const DiffHunkPreview: React.FC<DiffHunkPreviewProps> = ({
hunk,
maxHeight = 128,
className,
}) => {
const { resolvedMode, colorTheme } = useTheme();
const state = useReviewState();
const [expanded, setExpanded] = useState(false);
const fileDiff = useMemo(() => {
if (!hunk) return undefined;
try {
// Robustly handle all three hunk formats the tour agent might produce:
// 1. Full git diff: starts with "diff --git" — use as-is
// 2. File-level diff: starts with "--- " — prepend "diff --git" line only
// 3. Bare hunk: starts with "@@ " — prepend full synthetic headers
const patch = hunk.startsWith('diff --git')
? hunk
: hunk.startsWith('--- ')
? `diff --git a/file b/file\n${hunk}`
: `diff --git a/file b/file\n--- a/file\n+++ b/file\n${hunk}`;
return getSingularPatch(patch);
} catch {
return undefined;
}
}, [hunk]);
// Initialize synchronously so the very first render (inside a tooltip) is already themed.
// The lazy initializer reads computed CSS variables from the document root.
const [pierreTheme, setPierreTheme] = useState<{ type: 'dark' | 'light'; css: string }>(() => ({
type: resolvedMode ?? 'dark',
css: buildPierreCSS(resolvedMode ?? 'dark', state.fontFamily, state.fontSize),
}));
// Re-compute on theme / font changes
useEffect(() => {
const rafId = requestAnimationFrame(() => {
setPierreTheme({
type: resolvedMode ?? 'dark',
css: buildPierreCSS(resolvedMode ?? 'dark', state.fontFamily, state.fontSize),
});
});
return () => cancelAnimationFrame(rafId);
}, [resolvedMode, colorTheme, state.fontFamily, state.fontSize]);
const syntaxTheme = resolveSyntaxTheme(colorTheme, resolvedMode ?? 'dark');
if (!fileDiff) {
return (
<div className="px-3 py-2 text-[11px] text-muted-foreground/30 italic">
Diff not available
</div>
);
}
return (
<div className={`rounded overflow-hidden border border-border/20 ${className ?? ''}`}>
<div
className="overflow-hidden"
style={expanded ? undefined : { maxHeight }}
>
<FileDiff
fileDiff={fileDiff}
options={{
themeType: pierreTheme.type,
unsafeCSS: pierreTheme.css,
...(syntaxTheme && { theme: syntaxTheme }),
diffStyle: 'unified',
disableLineNumbers: true,
overflow: 'wrap',
}}
/>
</div>
{!expanded && (
<button
onClick={(e) => { e.stopPropagation(); setExpanded(true); }}
className="w-full text-[10px] text-muted-foreground hover:text-foreground py-1 bg-muted/20 border-t border-border/20 transition-colors"
>
Show full context
</button>
)}
</div>
);
};