From 70d4d6a5c17bce858457553fa2f72687984b3650 Mon Sep 17 00:00:00 2001 From: Andrey Skripalschikov Date: Sun, 10 May 2026 23:19:05 +0100 Subject: [PATCH] Add diff line background intensity option --- .../components/DiffHunkPreview.tsx | 21 +++- .../components/DiffOptionsPopover.tsx | 7 ++ .../review-editor/hooks/usePierreTheme.ts | 104 +++++++++++++++++- packages/review-editor/index.css | 17 --- packages/shared/config.ts | 2 + packages/ui/components/Settings.tsx | 18 +++ packages/ui/config/settings.ts | 21 ++++ 7 files changed, 166 insertions(+), 24 deletions(-) diff --git a/packages/review-editor/components/DiffHunkPreview.tsx b/packages/review-editor/components/DiffHunkPreview.tsx index 525040e9e..1731b5f30 100644 --- a/packages/review-editor/components/DiffHunkPreview.tsx +++ b/packages/review-editor/components/DiffHunkPreview.tsx @@ -1,9 +1,11 @@ import React, { useMemo, useState, useEffect } from 'react'; import { FileDiff } from '@pierre/diffs/react'; import { getSingularPatch } from '@pierre/diffs'; +import type { DiffLineBgIntensity } from '@plannotator/shared/config'; import { useTheme } from '@plannotator/ui/components/ThemeProvider'; +import { useConfigValue } from '@plannotator/ui/config'; import { useReviewState } from '../dock/ReviewStateContext'; -import { resolveSyntaxTheme } from '../hooks/usePierreTheme'; +import { resolveSyntaxTheme, buildLineBgOverrides } from '../hooks/usePierreTheme'; interface DiffHunkPreviewProps { /** Raw diff hunk string (unified diff format). */ @@ -17,7 +19,12 @@ interface DiffHunkPreviewProps { * 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 { +function buildPierreCSS( + mode: 'light' | 'dark', + fontFamily: string, + fontSize: string, + lineBgIntensity: DiffLineBgIntensity, +): string { try { const styles = getComputedStyle(document.documentElement); const bg = styles.getPropertyValue('--background').trim(); @@ -44,6 +51,7 @@ function buildPierreCSS(mode: 'dark' | 'light', fontFamily: string, fontSize: st [data-file-info] { display: none !important; } [data-diffs-header] { display: none !important; } ${fontCSS} + ${buildLineBgOverrides(lineBgIntensity, mode)} `; } catch { return ''; @@ -62,6 +70,7 @@ export const DiffHunkPreview: React.FC = ({ }) => { const { resolvedMode, colorTheme } = useTheme(); const state = useReviewState(); + const lineBgIntensity = useConfigValue('diffLineBgIntensity'); const [expanded, setExpanded] = useState(false); const fileDiff = useMemo(() => { @@ -86,19 +95,19 @@ export const DiffHunkPreview: React.FC = ({ // 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), + css: buildPierreCSS(resolvedMode ?? 'dark', state.fontFamily, state.fontSize, lineBgIntensity), })); - // Re-compute on theme / font changes + // Re-compute on theme / font / intensity changes useEffect(() => { const rafId = requestAnimationFrame(() => { setPierreTheme({ type: resolvedMode ?? 'dark', - css: buildPierreCSS(resolvedMode ?? 'dark', state.fontFamily, state.fontSize), + css: buildPierreCSS(resolvedMode ?? 'dark', state.fontFamily, state.fontSize, lineBgIntensity), }); }); return () => cancelAnimationFrame(rafId); - }, [resolvedMode, colorTheme, state.fontFamily, state.fontSize]); + }, [resolvedMode, colorTheme, state.fontFamily, state.fontSize, lineBgIntensity]); const syntaxTheme = resolveSyntaxTheme(colorTheme, resolvedMode ?? 'dark'); diff --git a/packages/review-editor/components/DiffOptionsPopover.tsx b/packages/review-editor/components/DiffOptionsPopover.tsx index f7711b2e1..e2ae27015 100644 --- a/packages/review-editor/components/DiffOptionsPopover.tsx +++ b/packages/review-editor/components/DiffOptionsPopover.tsx @@ -6,6 +6,7 @@ import { OVERFLOW_OPTIONS, INDICATOR_OPTIONS, LINE_DIFF_OPTIONS, + LINE_BG_INTENSITY_OPTIONS, } from '@plannotator/ui/components/Settings'; function CompactSegmented({ options, value, onChange }: { @@ -95,6 +96,7 @@ export const DiffOptionsPopover: React.FC = () => { const diffShowBackground = useConfigValue('diffShowBackground'); const diffHideWhitespace = useConfigValue('diffHideWhitespace'); const diffTabSize = useConfigValue('diffTabSize'); + const diffLineBgIntensity = useConfigValue('diffLineBgIntensity'); return ( @@ -144,6 +146,11 @@ export const DiffOptionsPopover: React.FC = () => {
configStore.set('diffShowLineNumbers', v)} label="Line numbers" /> configStore.set('diffShowBackground', v)} label="Diff background" /> + {diffShowBackground && ( +
+ configStore.set('diffLineBgIntensity', v)} /> +
+ )} configStore.set('diffHideWhitespace', v)} label="Hide whitespace" /> = { 'andromeeda': { dark: 'andromeeda', light: null }, @@ -51,11 +53,108 @@ export interface PierreTheme { syntaxTheme?: { dark: string; light: string }; } +/** + * Bg-share percentages plugged into Pierre's `--mix-light` / `--mix-dark` — + * the share of decoration-bg in `color-mix(decoration-bg X%, mix-target)` + * inside Pierre's `light-dark()` switch (`Light` applies in light themes, + * `Dark` in dark themes). Lower number = more line colour. We mirror Pierre's + * own pattern of slightly lower values for dark themes (its defaults are + * 88 / 80) since darker themes need a larger colour share to read at the + * same perceptual intensity. + * + * Driving the line bg through these vars (instead of overriding the final + * `background-color`) keeps Pierre's `--diffs-line-bg` pipeline intact, so + * selected / hovered / decorated states keep their state-specific visuals. + */ +interface IntensityConfig { + restMixLight: number; + restMixDark: number; + hoverMixLight: number; + hoverMixDark: number; +} + +const INTENSITY_CONFIG: Record, IntensityConfig> = { + normal: { restMixLight: 55, restMixDark: 45, hoverMixLight: 45, hoverMixDark: 35 }, + strong: { restMixLight: 35, restMixDark: 25, hoverMixLight: 25, hoverMixDark: 15 }, +}; + +/** + * The word-level chip is derived from the *actual computed line bg* (not from + * the theme's addition/deletion base colour) and nudged by this OKLCH-`l` + * delta — darker on light themes, lighter on dark themes. Pulling it off the + * line bg keeps the chip-vs-line relationship constant across intensities: + * Normal and Strong each produce a chip that's "one step deeper than this + * specific line", instead of one fixed chip that fights more or less against + * different lines. + */ +const EMPHASIS_LIGHTNESS_SHIFT = 0.07; + +/** + * @pierre/diffs hardcodes the diff-line bg as a ~12-20% mix of the line colour + * over the gutter (`--mix-light: 88%` / `--mix-dark: 80%`). To get a bolder + * look we lower those percentages on changed lines, so the library's existing + * `--diffs-line-bg` pipeline naturally produces stronger output. The hue comes + * from the resolved theme tokens (`--diffs-addition-base` / + * `--diffs-deletion-base`) — themes that customize diff colours keep them. + * + * `subtle` keeps Pierre's default line bg (its faint mix + alpha-overlay + * emphasis is exactly what Pierre's design intends), but still emits the + * "hide emphasis when diff bg is off" rule so that toggle behaves consistently + * at every intensity. + */ +export function buildLineBgOverrides(intensity: DiffLineBgIntensity, mode: 'light' | 'dark'): string { + // The library's word-emphasis rule (`[data-line-type=…] [data-diff-span] { + // background-color: var(--diffs-bg-addition-emphasis); }`) is NOT gated on + // `[data-background]`, so disabling diff backgrounds still leaves chips + // showing on plain lines. We hide them explicitly. Applies regardless of + // intensity so the "Diff background" toggle behaves consistently. + const hideEmphasisWithoutBg = ` + pre:not([data-background]) [data-line-type='change-addition'] [data-diff-span], + pre:not([data-background]) [data-line-type='change-deletion'] [data-diff-span] { + background-color: transparent !important; + } + `; + if (intensity === 'subtle') return hideEmphasisWithoutBg; + const cfg = INTENSITY_CONFIG[intensity]; + const lShift = mode === 'dark' + ? `+ ${EMPHASIS_LIGHTNESS_SHIFT}` + : `- ${EMPHASIS_LIGHTNESS_SHIFT}`; + // Targeting `[data-line]` and `[data-no-newline]` only — the actual code + // lines. Skipping `[data-gutter-buffer]` / `[data-column-number]` keeps the + // line-number gutter at the page bg (matching the existing + // `[data-column-number] { background-color: bg }` integration). Gating on + // `[data-background]` mirrors the library's own `:where([data-background])` + // scoping, so the "Diff background" toggle still turns line bgs off. + // + // Specificity is (0,0,4); wins against the library's (0,0,1) baseline and + // (0,0,3) hover rule. The `:not([data-hovered])` variant yields to the + // explicit `[data-hovered]` variant on hover. + const changedLine = + "[data-background] :is([data-line-type='change-addition'], [data-line-type='change-deletion'])" + + ":is([data-line], [data-no-newline])"; + return ` + ${changedLine}:not([data-hovered]) { + --mix-light: ${cfg.restMixLight}%; + --mix-dark: ${cfg.restMixDark}%; + } + ${changedLine}[data-hovered] { + --mix-light: ${cfg.hoverMixLight}%; + --mix-dark: ${cfg.hoverMixDark}%; + } + ${changedLine} { + --diffs-bg-addition-emphasis: oklch(from var(--diffs-computed-diff-line-bg) calc(l ${lShift}) c h); + --diffs-bg-deletion-emphasis: oklch(from var(--diffs-computed-diff-line-bg) calc(l ${lShift}) c h); + } + ${hideEmphasisWithoutBg} + `; +} + export function usePierreTheme(options?: { fontFamily?: string; fontSize?: string; showFileHeader?: boolean }): PierreTheme { const { colorTheme, resolvedMode } = useTheme(); const fontFamily = options?.fontFamily; const fontSize = options?.fontSize; const showFileHeader = options?.showFileHeader ?? false; + const lineBgIntensity = useConfigValue('diffLineBgIntensity'); const [pierreTheme, setPierreTheme] = useState(() => { const styles = getComputedStyle(document.documentElement); @@ -71,6 +170,7 @@ export function usePierreTheme(options?: { fontFamily?: string; fontSize?: strin :host { --diffs-bg-separator-override: color-mix(in srgb, ${fg} 8%, ${bg}); } [data-separator='line-info'], [data-separator='line-info-basic'] { height: 24px !important; } [data-separator='line-info'] { margin-block: 4px !important; } + ${buildLineBgOverrides(lineBgIntensity, resolvedMode ?? 'dark')} `}; }); @@ -170,10 +270,12 @@ export function usePierreTheme(options?: { fontFamily?: string; fontSize?: strin } ${fontCSS} + + ${buildLineBgOverrides(lineBgIntensity, resolvedMode)} `, }); }); - }, [resolvedMode, colorTheme, fontFamily, fontSize, showFileHeader]); + }, [resolvedMode, colorTheme, fontFamily, fontSize, showFileHeader, lineBgIntensity]); return pierreTheme; } diff --git a/packages/review-editor/index.css b/packages/review-editor/index.css index 6004cc230..a6c37ac83 100644 --- a/packages/review-editor/index.css +++ b/packages/review-editor/index.css @@ -112,23 +112,6 @@ diffs-container { --diffs-light: var(--foreground); } -/* Diff line backgrounds - synchronized with plannotator colors */ -.diff-line-addition { - background: oklch(from var(--success) l c h / 0.12); -} - -.diff-line-deletion { - background: oklch(from var(--destructive) l c h / 0.12); -} - -.light .diff-line-addition { - background: oklch(from var(--success) l c h / 0.15); -} - -.light .diff-line-deletion { - background: oklch(from var(--destructive) l c h / 0.15); -} - /* Review comment thread styling */ .review-comment { position: relative; diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 73876ab4d..f37fc9001 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -11,6 +11,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; import { execSync } from "child_process"; export type DefaultDiffType = 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all'; +export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong'; export interface DiffOptions { diffStyle?: 'split' | 'unified'; @@ -24,6 +25,7 @@ export interface DiffOptions { tabSize?: number; hideWhitespace?: boolean; defaultDiffType?: DefaultDiffType; + lineBgIntensity?: DiffLineBgIntensity; } /** Single conventional comment label entry stored in config.json */ diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 3e95dfa37..e2982315b 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import type { Origin } from '@plannotator/shared/agents'; +import type { DiffLineBgIntensity } from '@plannotator/shared/config'; import { configStore, useConfigValue } from '../config'; import { loadDiffFont } from '../utils/diffFonts'; import { TaterSpritePullup } from './TaterSpritePullup'; @@ -122,6 +123,11 @@ export const LINE_DIFF_OPTIONS = [ { value: 'char' as const, label: 'Char' }, { value: 'none' as const, label: 'None' }, ]; +export const LINE_BG_INTENSITY_OPTIONS: { value: DiffLineBgIntensity; label: string }[] = [ + { value: 'subtle', label: 'Subtle' }, + { value: 'normal', label: 'Normal' }, + { value: 'strong', label: 'Strong' }, +]; const DEFAULT_DIFF_TYPE_OPTIONS = [ { value: 'uncommitted' as const, label: 'All Changes', description: "Everything you've changed since your last commit" }, { value: 'unstaged' as const, label: 'Unstaged', description: "Only changes you haven't staged yet" }, @@ -229,6 +235,7 @@ const ReviewDisplayTab: React.FC = () => { const diffLineDiffType = useConfigValue('diffLineDiffType'); const diffShowLineNumbers = useConfigValue('diffShowLineNumbers'); const diffShowBackground = useConfigValue('diffShowBackground'); + const diffLineBgIntensity = useConfigValue('diffLineBgIntensity'); const diffHideWhitespace = useConfigValue('diffHideWhitespace'); const diffFontFamily = useConfigValue('diffFontFamily'); const diffFontSize = useConfigValue('diffFontSize'); @@ -363,6 +370,17 @@ const ReviewDisplayTab: React.FC = () => { description="Colored backgrounds on added/deleted lines" /> + {/* Line Background Intensity */} + {diffShowBackground && ( +
+
+
Line Background Intensity
+
How prominent the colored line backgrounds appear
+
+ configStore.set('diffLineBgIntensity', v)} /> +
+ )} +
{/* Hide Whitespace */} diff --git a/packages/ui/config/settings.ts b/packages/ui/config/settings.ts index 18d1a8d3e..01f6d0cac 100644 --- a/packages/ui/config/settings.ts +++ b/packages/ui/config/settings.ts @@ -9,9 +9,15 @@ * Add new settings here. Cookie-only settings omit serverKey. */ +import type { DiffLineBgIntensity } from '@plannotator/shared/config'; import { storage } from '../utils/storage'; import { generateIdentity } from '../utils/generateIdentity'; +const DIFF_LINE_BG_INTENSITY_VALUES = ['subtle', 'normal', 'strong'] as const; +function isDiffLineBgIntensity(v: unknown): v is DiffLineBgIntensity { + return typeof v === 'string' && (DIFF_LINE_BG_INTENSITY_VALUES as readonly string[]).includes(v); +} + export interface SettingDef { defaultValue: T | (() => T); fromCookie: () => T | undefined; @@ -195,6 +201,21 @@ export const SETTINGS = { }, toServer: (v: number) => ({ diffOptions: { tabSize: v } }), }, + diffLineBgIntensity: { + defaultValue: 'subtle' as DiffLineBgIntensity, + fromCookie: () => { + const v = storage.getItem('plannotator-diff-line-bg-intensity'); + return isDiffLineBgIntensity(v) ? v : undefined; + }, + toCookie: (v: DiffLineBgIntensity) => + storage.setItem('plannotator-diff-line-bg-intensity', v), + serverKey: 'diffOptions', + fromServer: (sc: Record) => { + const v = (sc.diffOptions as Record | undefined)?.lineBgIntensity; + return isDiffLineBgIntensity(v) ? v : undefined; + }, + toServer: (v: DiffLineBgIntensity) => ({ diffOptions: { lineBgIntensity: v } }), + }, conventionalComments: { defaultValue: false as boolean, fromCookie: () => {