diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 1c17d58215ea..f9eaf269a957 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -21,7 +21,9 @@ const clientSettings: ClientSettings = {
confirmThreadArchive: true,
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
+ diffColorScheme: "orange-blue",
diffIgnoreWhitespace: true,
+ diffIndicatorStyle: "classic",
environmentIdentificationMode: "artwork",
favorites: [],
fontFamilyCode: "",
diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx
index 1212030ba339..6ec1710d7caa 100644
--- a/apps/web/src/components/chat/ChangedFilesTree.tsx
+++ b/apps/web/src/components/chat/ChangedFilesTree.tsx
@@ -53,7 +53,6 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]);
const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]);
const compactPreviewVisible = showCompactPreview && !expanded;
-
return (
-
+
+{formatCompactDiffCount(additions)}
-
+
-{formatCompactDiffCount(deletions)}
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index c90aa771f8d1..c4eacb471aed 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -30,7 +30,6 @@ import {
type ReactNode,
} from "react";
import { LegendList, type LegendListRef } from "@legendapp/list/react";
-import { FileDiff } from "@pierre/diffs/react";
import {
deriveTimelineEntries,
workEntryIndicatesToolFailure,
@@ -45,6 +44,7 @@ import {
resolveFileDiffPath,
} from "../../lib/diffRendering";
import ChatMarkdown from "../ChatMarkdown";
+import { StyledFileDiff } from "../diffs/StyledDiffCodeView";
import {
BotIcon,
CheckIcon,
@@ -1869,7 +1869,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte
)}
{renderablePatch?.kind === "files" &&
renderablePatch.files.map((fileDiff) => (
- ({
codeViewClassName: null as string | null,
codeViewOptions: null as Record | null,
+ fileDiffClassName: null as string | null,
+ fileDiffOptions: null as Record | null,
}));
vi.mock("@pierre/diffs/react", () => ({
@@ -12,14 +15,21 @@ vi.mock("@pierre/diffs/react", () => ({
testState.codeViewOptions = props.options;
return null;
},
+ FileDiff: (props: { className: string; options: Record }) => {
+ testState.fileDiffClassName = props.className;
+ testState.fileDiffOptions = props.options;
+ return null;
+ },
}));
-import { StyledDiffCodeView } from "./StyledDiffCodeView";
+import { StyledDiffCodeView, StyledFileDiff } from "./StyledDiffCodeView";
describe("StyledDiffCodeView", () => {
beforeEach(() => {
testState.codeViewClassName = null;
testState.codeViewOptions = null;
+ testState.fileDiffClassName = null;
+ testState.fileDiffOptions = null;
});
it("always pairs the shared diff styling with its virtualized geometry", () => {
@@ -36,12 +46,15 @@ describe("StyledDiffCodeView", () => {
);
expect(testState.codeViewClassName).toBe(
- "diff-render-surface [--code-background:var(--background)] outline-none min-h-0",
+ "diff-render-surface [--code-background:var(--background)] outline-none " +
+ "[--t3-diff-addition-color:var(--success)] " +
+ "[--t3-diff-deletion-color:var(--destructive)] min-h-0",
);
expect(testState.codeViewOptions).toMatchObject({
theme: "pierre-dark",
stickyHeaders: true,
loadDiffFiles,
+ diffIndicators: "bars",
itemMetrics: {
diffHeaderHeight: 32,
hunkSeparatorHeight: 24,
@@ -57,4 +70,29 @@ describe("StyledDiffCodeView", () => {
expect.stringContaining(")[data-expand-index]\n [data-unmodified-lines]"),
);
});
+
+ it("applies the shared appearance to standalone file diffs", () => {
+ const fileDiff = { name: "src/app.ts", hunks: [] } as unknown as FileDiffMetadata;
+ renderToStaticMarkup(
+ ,
+ );
+
+ expect(testState.fileDiffClassName).toBe(
+ "diff-render-surface [--code-background:var(--background)] outline-none " +
+ "[--t3-diff-addition-color:var(--success)] " +
+ "[--t3-diff-deletion-color:var(--destructive)] rounded-md",
+ );
+ expect(testState.fileDiffOptions).toMatchObject({
+ collapsed: false,
+ theme: "pierre-dark",
+ diffIndicators: "bars",
+ });
+ expect(testState.fileDiffOptions?.unsafeCSS).toEqual(
+ expect.stringContaining("--diffs-addition-base"),
+ );
+ });
});
diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.tsx
index 14939de09820..7c4f6304e8b7 100644
--- a/apps/web/src/components/diffs/StyledDiffCodeView.tsx
+++ b/apps/web/src/components/diffs/StyledDiffCodeView.tsx
@@ -4,12 +4,16 @@ import {
type CodeViewHandle,
type CodeViewProps,
type ControlledCodeViewProps,
+ FileDiff,
+ type FileDiffProps,
type UncontrolledCodeViewProps,
} from "@pierre/diffs/react";
/* oxlint-enable eslint/no-restricted-imports */
import type { Ref } from "react";
+import type { DiffColorScheme } from "@t3tools/contracts/settings";
-import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering";
+import { useClientSettings } from "~/hooks/useSettings";
+import { DIFF_SURFACE_THEME_UNSAFE_CSS, getDiffColorSchemeClassName } from "~/lib/diffRendering";
const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS}
:is(
@@ -258,9 +262,13 @@ const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS}
}
`;
+function getDiffSurfaceClassName(colorScheme: DiffColorScheme, className?: string): string {
+ return `diff-render-surface [--code-background:var(--background)] outline-none ${getDiffColorSchemeClassName(colorScheme)}${className ? ` ${className}` : ""}`;
+}
+
export type StyledDiffCodeViewOptions = Omit<
NonNullable["options"]>,
- "unsafeCSS" | "itemMetrics" | "layout"
+ "unsafeCSS" | "itemMetrics" | "layout" | "diffIndicators"
>;
type StyledDiffCodeViewProps = (
@@ -284,19 +292,18 @@ export function StyledDiffCodeView({
unsafeCSSExtra,
...props
}: StyledDiffCodeViewProps) {
+ const { diffColorScheme, diffIndicatorStyle } = useClientSettings();
+
return (
{...props}
{...(viewerRef ? { ref: viewerRef } : {})}
// The custom element itself is focusable for keyboard scrolling. Its native outline sits
// outside the panel clipping boundary; actual controls inside retain their own indicators.
- className={
- className
- ? `diff-render-surface [--code-background:var(--background)] outline-none ${className}`
- : "diff-render-surface [--code-background:var(--background)] outline-none"
- }
+ className={getDiffSurfaceClassName(diffColorScheme, className)}
options={{
...options,
+ diffIndicators: diffIndicatorStyle,
unsafeCSS: unsafeCSSExtra
? `${DIFF_VIEW_UNSAFE_CSS}\n${unsafeCSSExtra}`
: DIFF_VIEW_UNSAFE_CSS,
@@ -320,3 +327,30 @@ export function StyledDiffCodeView({
/>
);
}
+
+type StyledFileDiffProps = Omit, "options"> & {
+ readonly options?: Omit<
+ NonNullable["options"]>,
+ "unsafeCSS" | "diffIndicators"
+ >;
+};
+
+export function StyledFileDiff({
+ options,
+ className,
+ ...props
+}: StyledFileDiffProps) {
+ const { diffColorScheme, diffIndicatorStyle } = useClientSettings();
+
+ return (
+
+ {...props}
+ className={getDiffSurfaceClassName(diffColorScheme, className)}
+ options={{
+ ...options,
+ diffIndicators: diffIndicatorStyle,
+ unsafeCSS: DIFF_SURFACE_THEME_UNSAFE_CSS,
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx
index 9161e4a82007..72448c8d434a 100644
--- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx
+++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx
@@ -384,10 +384,12 @@ export function PullRequestDiffStat({
}
return (
-
+
+{additions.toLocaleString()}
- -{deletions.toLocaleString()}
+
+ -{deletions.toLocaleString()}
+
);
}
diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx
index 57d714d2042d..b23a5855210d 100644
--- a/apps/web/src/components/settings/SettingsFontPreviews.tsx
+++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx
@@ -3,8 +3,15 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor";
import { terminalThemeFromApp } from "../ThreadTerminalDrawer";
import { useTheme } from "../../hooks/useTheme";
+import { useClientSettings } from "../../hooks/useSettings";
import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
-import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering";
+import {
+ DIFF_SURFACE_THEME_UNSAFE_CSS,
+ getDiffColorSchemeClassName,
+ resolveDiffThemeName,
+ type DiffThemeName,
+} from "../../lib/diffRendering";
+import type { DiffIndicatorStyle } from "@t3tools/contracts/settings";
import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface";
// The font previews are the real surfaces, not lookalikes: the composer's
@@ -72,73 +79,64 @@ const DIFF_PREVIEW_PATCH = [
// (toggling Advanced) and lock in an unhighlighted frame; a static preview
// needs none of that lifecycle, so it uses the deterministic renderer and
// injects the finished HTML into a shadow root, exactly as FileDiff would.
-const diffPreviewHtmlByTheme = new Map>();
+const diffPreviewHtml = new Map>();
-function loadDiffPreviewHtml(theme: DiffThemeName): Promise {
- let promise = diffPreviewHtmlByTheme.get(theme);
+function loadDiffPreviewHtml(
+ theme: DiffThemeName,
+ diffIndicators: DiffIndicatorStyle,
+): Promise {
+ const key = `${theme}:${diffIndicators}`;
+ let promise = diffPreviewHtml.get(key);
if (promise === undefined) {
promise = preloadPatchFile({
patch: DIFF_PREVIEW_PATCH,
- options: { diffStyle: "unified", theme },
+ options: {
+ diffIndicators,
+ diffStyle: "unified",
+ theme,
+ unsafeCSS: DIFF_SURFACE_THEME_UNSAFE_CSS,
+ },
}).then((results) => results.map((result) => result.prerenderedHTML));
- diffPreviewHtmlByTheme.set(theme, promise);
+ diffPreviewHtml.set(key, promise);
}
return promise;
}
-// Pierre's prerendered stylesheet bakes its own light/dark surface colors
-// into the shadow root's @layer rules. These unlayered rules win the cascade
-// without !important and re-point the surfaces at the app's code tokens
-// (custom properties inherit across the shadow boundary), so the preview
-// follows the active theme exactly like the real diff panel does.
-const DIFF_PREVIEW_THEME_BRIDGE = `
- :host {
- color: var(--code-foreground);
- background-color: var(--code-background);
- --diffs-fg: var(--code-foreground);
- --diffs-bg: var(--code-background);
- --diffs-light-bg: var(--code-background);
- --diffs-dark-bg: var(--code-background);
- }
- [data-diffs-header] {
- background-color: var(--code-background);
- color: var(--code-foreground);
- }
-`;
-
-function StaticDiffHtml({ html }: { html: string }) {
+function StaticDiffHtml({ html, className }: { html: string; className: string }) {
const hostRef = useRef(null);
useEffect(() => {
const host = hostRef.current;
if (host === null) return;
const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
shadow.innerHTML = html;
- const bridge = document.createElement("style");
- bridge.textContent = DIFF_PREVIEW_THEME_BRIDGE;
- shadow.append(bridge);
}, [html]);
- return ;
+ return ;
}
/** The diff panel's file diff, statically rendered by its real pipeline. */
-export function CodeFontPreview() {
+export function DiffPreview() {
const { resolvedTheme } = useTheme();
+ const { diffColorScheme, diffIndicatorStyle } = useClientSettings();
const themeName = resolveDiffThemeName(resolvedTheme);
const [htmlByFile, setHtmlByFile] = useState(null);
useEffect(() => {
let cancelled = false;
- void loadDiffPreviewHtml(themeName).then((html) => {
+ void loadDiffPreviewHtml(themeName, diffIndicatorStyle).then((html) => {
if (!cancelled) setHtmlByFile(html);
});
return () => {
cancelled = true;
};
- }, [themeName]);
+ }, [diffIndicatorStyle, themeName]);
if (htmlByFile === null) return null;
return (
{htmlByFile.map((html) => (
-
+
))}
);
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 9539f95914cb..f630f1fb69ea 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -104,7 +104,7 @@ import {
resolveTerminalFontSizePreference,
TYPOGRAPHY_ADVANCED_STORAGE_KEY,
} from "../../appearanceFonts";
-import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
+import { DiffPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";
import {
NumberField,
@@ -499,6 +499,12 @@ export function useSettingsRestore(onRestored?: () => void) {
: []),
...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
...getChangedTypographySettingLabels(settings),
+ ...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme
+ ? ["Diff colors"]
+ : []),
+ ...(settings.diffIndicatorStyle !== DEFAULT_UNIFIED_SETTINGS.diffIndicatorStyle
+ ? ["Diff markers"]
+ : []),
...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
? ["Diff whitespace changes"]
: []),
@@ -550,6 +556,8 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.addProjectBaseDirectory,
settings.defaultThreadEnvMode,
settings.newWorktreesStartFromOrigin,
+ settings.diffColorScheme,
+ settings.diffIndicatorStyle,
settings.diffIgnoreWhitespace,
settings.environmentIdentificationMode,
settings.fontFamilyCode,
@@ -640,6 +648,8 @@ export function useSettingsRestore(onRestored?: () => void) {
updateSettings({
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
+ diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
+ diffIndicatorStyle: DEFAULT_UNIFIED_SETTINGS.diffIndicatorStyle,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
@@ -1218,7 +1228,7 @@ function CodeFontRow({
defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeCode,
onChange: (fontSizeCode) => updateSettings({ fontSizeCode }),
}}
- preview={preview ?? }
+ {...(preview !== undefined ? { preview } : {})}
/>
);
}
@@ -1319,12 +1329,103 @@ function WordWrapRow() {
);
}
+function DiffAppearanceRows() {
+ const settings = usePrimarySettings();
+ const updateSettings = useUpdatePrimarySettings();
+ return (
+ <>
+
+ updateSettings({ diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ }
+ />
+
+
+ updateSettings({
+ diffIndicatorStyle: DEFAULT_UNIFIED_SETTINGS.diffIndicatorStyle,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ }
+ >
+
+
+ >
+ );
+}
+
function FontSettingsGroup() {
return (
<>
+
>
@@ -1345,23 +1446,21 @@ function SimpleFontRows() {
title="Monospace font"
description="Code blocks, diffs, file previews, and the terminal."
preview={
- <>
-
-
- >
+
}
/>
+
>
);
}
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index e49f77a834eb..b03c52049840 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -63,6 +63,16 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Glass opacity",
to: "/settings/appearance",
},
+ {
+ id: "diff-colors",
+ title: "Diff colors",
+ to: "/settings/appearance",
+ },
+ {
+ id: "diff-markers",
+ title: "Diff markers",
+ to: "/settings/appearance",
+ },
{
id: "environment-identification",
title: "Environment identification",
diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts
index c1556981743d..605e607332dd 100644
--- a/apps/web/src/lib/diffRendering.ts
+++ b/apps/web/src/lib/diffRendering.ts
@@ -1,5 +1,12 @@
import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
import type { FileDiffMetadata } from "@pierre/diffs/types";
+import type { DiffColorScheme } from "@t3tools/contracts/settings";
+
+export function getDiffColorSchemeClassName(scheme: DiffColorScheme): string {
+ return scheme === "orange-blue"
+ ? "[--t3-diff-addition-color:var(--info)] [--t3-diff-deletion-color:var(--warning)]"
+ : "[--t3-diff-addition-color:var(--success)] [--t3-diff-deletion-color:var(--destructive)]";
+}
export const DIFF_THEME_NAMES = {
light: "pierre-light",
@@ -204,6 +211,10 @@ export const DIFF_SURFACE_THEME_UNSAFE_CSS = `
--diffs-dark-bg: var(--code-background) !important;
--diffs-token-light-bg: transparent;
--diffs-token-dark-bg: transparent;
+ --diffs-addition-color-override: var(--t3-diff-addition-color, var(--success)) !important;
+ --diffs-deletion-color-override: var(--t3-diff-deletion-color, var(--destructive)) !important;
+ --diffs-addition-base: var(--diffs-addition-color-override) !important;
+ --diffs-deletion-base: var(--diffs-deletion-color-override) !important;
/* Gutter, context, and row tints all derive from the code surface the diff
body sits on — mixing from the canvas leaves the gutter looking unthemed
@@ -218,37 +229,41 @@ export const DIFF_SURFACE_THEME_UNSAFE_CSS = `
--diffs-bg-buffer-override: color-mix(in srgb, var(--code-background) 90%, var(--code-foreground));
--diffs-bg-addition-override: light-dark(
- color-mix(in srgb, var(--code-background) 50%, var(--success)),
- color-mix(in srgb, var(--code-background) 70%, var(--success))
+ color-mix(in srgb, var(--code-background) 50%, var(--diffs-addition-color-override)),
+ color-mix(in srgb, var(--code-background) 70%, var(--diffs-addition-color-override))
);
--diffs-bg-addition-number-override: light-dark(
- color-mix(in srgb, var(--code-background) 35%, var(--success)),
- color-mix(in srgb, var(--code-background) 60%, var(--success))
+ color-mix(in srgb, var(--code-background) 35%, var(--diffs-addition-color-override)),
+ color-mix(in srgb, var(--code-background) 60%, var(--diffs-addition-color-override))
+ );
+ --diffs-bg-addition-hover-override: color-mix(
+ in srgb,
+ var(--code-background) 85%,
+ var(--diffs-addition-color-override)
);
- --diffs-bg-addition-hover-override: color-mix(in srgb, var(--code-background) 85%, var(--success));
--diffs-bg-addition-emphasis-override: color-mix(
in srgb,
var(--code-background) 80%,
- var(--success)
+ var(--diffs-addition-color-override)
);
--diffs-bg-deletion-override: light-dark(
- color-mix(in srgb, var(--code-background) 50%, var(--destructive)),
- color-mix(in srgb, var(--code-background) 70%, var(--destructive))
+ color-mix(in srgb, var(--code-background) 50%, var(--diffs-deletion-color-override)),
+ color-mix(in srgb, var(--code-background) 70%, var(--diffs-deletion-color-override))
);
--diffs-bg-deletion-number-override: light-dark(
- color-mix(in srgb, var(--code-background) 35%, var(--destructive)),
- color-mix(in srgb, var(--code-background) 60%, var(--destructive))
+ color-mix(in srgb, var(--code-background) 35%, var(--diffs-deletion-color-override)),
+ color-mix(in srgb, var(--code-background) 60%, var(--diffs-deletion-color-override))
);
--diffs-bg-deletion-hover-override: color-mix(
in srgb,
var(--code-background) 85%,
- var(--destructive)
+ var(--diffs-deletion-color-override)
);
--diffs-bg-deletion-emphasis-override: color-mix(
in srgb,
var(--code-background) 80%,
- var(--destructive)
+ var(--diffs-deletion-color-override)
);
background-color: var(--diffs-bg) !important;
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 91381301418e..40288f9aa0db 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -30,6 +30,7 @@ import {
} from "../components/ui/toast";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
import { applyAppearanceFontVariables } from "~/appearanceFonts";
+import { getDiffColorSchemeClassName } from "~/lib/diffRendering";
import { useClientSettings } from "../hooks/useSettings";
import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal";
import {
@@ -133,6 +134,7 @@ function RootRouteView() {
+
{primaryEnvironmentAuthenticated ? : null}
@@ -194,6 +196,18 @@ function FontAppearanceSync() {
return null;
}
+function DiffAppearanceSync() {
+ const diffColorScheme = useClientSettings((settings) => settings.diffColorScheme);
+
+ useEffect(() => {
+ const classNames = getDiffColorSchemeClassName(diffColorScheme).split(" ");
+ document.documentElement.classList.add(...classNames);
+ return () => document.documentElement.classList.remove(...classNames);
+ }, [diffColorScheme]);
+
+ return null;
+}
+
function DocumentTitleSync() {
const primaryServerVersion =
useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
diff --git a/docs/README.md b/docs/README.md
index f1698a66e179..2a69d1045cba 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -8,6 +8,7 @@
- [Organizing threads](./user/thread-sidebar.md)
- [Review usage](./user/usage.md)
- [Customize a project icon](./user/project-settings.md)
+- [Diff appearance](./user/appearance.md)
- [Mobile appearance](./user/mobile-appearance.md)
- [Remote access](./user/remote-access.md)
- [Keeping app and server in sync](./user/updating.md)
diff --git a/docs/user/appearance.md b/docs/user/appearance.md
new file mode 100644
index 000000000000..662383e76474
--- /dev/null
+++ b/docs/user/appearance.md
@@ -0,0 +1,9 @@
+# Diff appearance
+
+Open **Settings** → **Appearance** in the web or desktop app. **Diff colors** switches between
+the standard red/green palette and an orange/blue palette.
+
+**Diff markers** switches between compact bar indicators and classic `+` and `−` markers at the
+start of changed lines.
+
+Both preferences apply to diff panels and pull request reviews on the current device.
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 46a4d25ac303..d4fb87a182bb 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -18,9 +18,13 @@ const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
const encodeServerSettings = Schema.encodeSync(ServerSettings);
-describe("ClientSettings word wrap", () => {
- it("defaults word wrap on", () => {
- expect(decodeClientSettings({}).wordWrap).toBe(true);
+describe("ClientSettings display defaults", () => {
+ it("preserves existing display behavior", () => {
+ const settings = decodeClientSettings({});
+
+ expect(settings.wordWrap).toBe(true);
+ expect(settings.diffColorScheme).toBe("red-green");
+ expect(settings.diffIndicatorStyle).toBe("bars");
});
it("ignores obsolete wrapping preferences", () => {
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 96ee5b85c05a..e19b632aa84f 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -116,6 +116,12 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill",
export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type;
export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork";
+export const DiffColorScheme = Schema.Literals(["red-green", "orange-blue"]);
+export type DiffColorScheme = typeof DiffColorScheme.Type;
+
+export const DiffIndicatorStyle = Schema.Literals(["bars", "classic"]);
+export type DiffIndicatorStyle = typeof DiffIndicatorStyle.Type;
+
/**
* A user-chosen font family (a single name or a comma-separated list). Empty
* means "use the app default"; clients compose their own fallback stacks.
@@ -159,6 +165,8 @@ export const ClientSettingsSchema = Schema.Struct({
dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
),
+ diffColorScheme: DiffColorScheme.pipe(Schema.withDecodingDefault(Effect.succeed("red-green"))),
+ diffIndicatorStyle: DiffIndicatorStyle.pipe(Schema.withDecodingDefault(Effect.succeed("bars"))),
diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
environmentIdentificationMode: EnvironmentIdentificationMode.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE)),
@@ -868,6 +876,8 @@ export const ClientSettingsPatch = Schema.Struct({
confirmQuit: Schema.optionalKey(Schema.Boolean),
confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
+ diffColorScheme: Schema.optionalKey(DiffColorScheme),
+ diffIndicatorStyle: Schema.optionalKey(DiffIndicatorStyle),
diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),
glassOpacity: Schema.optionalKey(GlassOpacity),