diff --git a/.changeset/high-contrast-theme.md b/.changeset/high-contrast-theme.md new file mode 100644 index 00000000..5bdb57dc --- /dev/null +++ b/.changeset/high-contrast-theme.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Add high-contrast dark and light built-in themes. diff --git a/README.md b/README.md index 19ede4bc..0f3b4cd8 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ You can persist preferences to a config file: Example: ```toml -theme = "graphite" # graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn, custom +theme = "graphite" # graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn, high-contrast-dark, high-contrast-light, custom mode = "auto" # auto, split, stack vcs = "git" # git, jj, sl watch = false @@ -139,7 +139,7 @@ Custom themes can inherit from any built-in base theme and override only the col theme = "custom" [custom_theme] -base = "graphite" # graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn +base = "graphite" # graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn, high-contrast-dark, high-contrast-light label = "My Theme" accent = "#7fd1ff" panel = "#10161d" diff --git a/docs/opentui-component.md b/docs/opentui-component.md index 91eefba2..b27908b2 100644 --- a/docs/opentui-component.md +++ b/docs/opentui-component.md @@ -190,19 +190,19 @@ If you need direct access to Pierre's parser, `parsePatchFiles(...)` is still re ## Common props -| Prop | Type | Default | Notes | -| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------- | -| `layout` | `"split" \| "stack"` | `"split"` | Chooses side-by-side or stacked rendering. | -| `width` | `number` | — | Required content width in terminal columns. | -| `theme` | `"graphite" \| "midnight" \| "paper" \| "ember" \| "catppuccin-latte" \| "catppuccin-frappe" \| "catppuccin-macchiato" \| "catppuccin-mocha" \| "zenburn"` | `"graphite"` | Matches Hunk's built-in themes. | -| `showLineNumbers` | `boolean` | `true` | Toggles line-number columns. | -| `showHunkHeaders` | `boolean` | `true` | Toggles `@@ ... @@` hunk header rows. | -| `showFileSeparators` | `boolean` | `true` | Toggles separator rows between files in `HunkReviewStream`. | -| `wrapLines` | `boolean` | `false` | Wraps long lines instead of clipping horizontally. | -| `horizontalOffset` | `number` | `0` | Scroll offset for non-wrapped code rows. | -| `highlight` | `boolean` | `true` | Enables syntax highlighting. | -| `selectedHunkIndex` | `number` | `0` | Highlights one hunk as the active target for single-file components. | -| `scrollable` | `boolean` | `true` | `HunkDiffView` only; primitives should be wrapped in OpenTUI scrollbox when needed. | +| Prop | Type | Default | Notes | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------- | +| `layout` | `"split" \| "stack"` | `"split"` | Chooses side-by-side or stacked rendering. | +| `width` | `number` | — | Required content width in terminal columns. | +| `theme` | `"graphite" \| "midnight" \| "paper" \| "ember" \| "catppuccin-latte" \| "catppuccin-frappe" \| "catppuccin-macchiato" \| "catppuccin-mocha" \| "zenburn" \| "high-contrast-dark" \| "high-contrast-light"` | `"graphite"` | Matches Hunk's built-in themes. | +| `showLineNumbers` | `boolean` | `true` | Toggles line-number columns. | +| `showHunkHeaders` | `boolean` | `true` | Toggles `@@ ... @@` hunk header rows. | +| `showFileSeparators` | `boolean` | `true` | Toggles separator rows between files in `HunkReviewStream`. | +| `wrapLines` | `boolean` | `false` | Wraps long lines instead of clipping horizontally. | +| `horizontalOffset` | `number` | `0` | Scroll offset for non-wrapped code rows. | +| `highlight` | `boolean` | `true` | Enables syntax highlighting. | +| `selectedHunkIndex` | `number` | `0` | Highlights one hunk as the active target for single-file components. | +| `scrollable` | `boolean` | `true` | `HunkDiffView` only; primitives should be wrapped in OpenTUI scrollbox when needed. | ## Other exports diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 576d23c3..53e53c34 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -158,6 +158,8 @@ describe("config resolution", () => { "catppuccin-macchiato", "catppuccin-mocha", "zenburn", + "high-contrast-dark", + "high-contrast-light", ])("accepts custom theme base id: %s", (base) => { const home = createTempDir("hunk-config-home-"); mkdirSync(join(home, ".config", "hunk"), { recursive: true }); @@ -188,7 +190,7 @@ describe("config resolution", () => { env: { HOME: home }, }), ).toThrow( - "Expected custom_theme.base to be one of: graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn.", + "Expected custom_theme.base to be one of: graphite, midnight, paper, ember, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, zenburn, high-contrast-dark, high-contrast-light.", ); }); diff --git a/src/core/config.ts b/src/core/config.ts index e4a76038..8a89f58f 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -22,6 +22,8 @@ const BUILT_IN_THEME_IDS = [ "catppuccin-macchiato", "catppuccin-mocha", "zenburn", + "high-contrast-dark", + "high-contrast-light", ] as const; const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i; const CUSTOM_THEME_COLOR_KEYS = [ diff --git a/src/opentui/HunkDiffView.test.tsx b/src/opentui/HunkDiffView.test.tsx index 1510ac5a..45af9c72 100644 --- a/src/opentui/HunkDiffView.test.tsx +++ b/src/opentui/HunkDiffView.test.tsx @@ -165,6 +165,8 @@ describe("OpenTUI public components", () => { "catppuccin-macchiato", "catppuccin-mocha", "zenburn", + "high-contrast-dark", + "high-contrast-light", ]); }); }); diff --git a/src/opentui/themes.ts b/src/opentui/themes.ts index 94dcfffc..5081f969 100644 --- a/src/opentui/themes.ts +++ b/src/opentui/themes.ts @@ -8,6 +8,8 @@ export const HUNK_DIFF_THEME_NAMES = [ "catppuccin-macchiato", "catppuccin-mocha", "zenburn", + "high-contrast-dark", + "high-contrast-light", ] as const; export type HunkDiffThemeName = (typeof HUNK_DIFF_THEME_NAMES)[number]; diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index e2a6a202..0a4c4e7a 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -206,6 +206,8 @@ describe("ui helpers", () => { "Catppuccin Macchiato", "Catppuccin Mocha", "Zenburn", + "High Contrast Dark", + "High Contrast Light", ]); expect( menus.theme.some( @@ -263,6 +265,8 @@ describe("ui helpers", () => { "Catppuccin Macchiato", "Catppuccin Mocha", "Zenburn", + "High Contrast Dark", + "High Contrast Light", "My Theme", ]); expect( @@ -537,6 +541,8 @@ describe("ui helpers", () => { expect(custom.syntaxColors.keyword).toBe("#123456"); expect(missingCustom.id).toBe("graphite"); expect(resolveTheme("ember", null).syntaxStyle).toBeDefined(); + expect(resolveTheme("high-contrast-dark", null).syntaxStyle).toBeDefined(); + expect(resolveTheme("high-contrast-light", null).syntaxStyle).toBeDefined(); expect(custom.syntaxStyle).toBeDefined(); expect(resolveTheme("catppuccin-latte", null).syntaxStyle).toBeDefined(); expect(resolveTheme("catppuccin-frappe", null).syntaxStyle).toBeDefined(); diff --git a/src/ui/themes.test.ts b/src/ui/themes.test.ts index 0472b89f..d286711e 100644 --- a/src/ui/themes.test.ts +++ b/src/ui/themes.test.ts @@ -132,6 +132,41 @@ describe("themes", () => { }); }); + test("resolves high-contrast variants by theme id with strong text contrast", () => { + const dark = resolveTheme("high-contrast-dark", null); + const light = resolveTheme("high-contrast-light", null); + + expect(dark.id).toBe("high-contrast-dark"); + expect(dark.label).toBe("High Contrast Dark"); + expect(dark.appearance).toBe("dark"); + expect(dark.background).toBe("#000000"); + expect(dark.text).toBe("#ffffff"); + expect(hexColorDistance(dark.text, dark.background)).toBeGreaterThan(700); + expect(hexColorDistance(dark.addedContentBg, dark.contextBg)).toBeGreaterThan( + hexColorDistance(dark.addedBg, dark.contextBg), + ); + expect(hexColorDistance(dark.removedContentBg, dark.contextBg)).toBeGreaterThan( + hexColorDistance(dark.removedBg, dark.contextBg), + ); + expect(hexColorDistance(dark.addedContentBg, dark.addedBg)).toBeGreaterThanOrEqual(90); + expect(hexColorDistance(dark.removedContentBg, dark.removedBg)).toBeGreaterThanOrEqual(90); + + expect(light.id).toBe("high-contrast-light"); + expect(light.label).toBe("High Contrast Light"); + expect(light.appearance).toBe("light"); + expect(light.background).toBe("#ffffff"); + expect(light.text).toBe("#000000"); + expect(hexColorDistance(light.text, light.background)).toBeGreaterThan(700); + expect(hexColorDistance(light.addedContentBg, light.contextBg)).toBeGreaterThan( + hexColorDistance(light.addedBg, light.contextBg), + ); + expect(hexColorDistance(light.removedContentBg, light.contextBg)).toBeGreaterThan( + hexColorDistance(light.removedBg, light.contextBg), + ); + expect(hexColorDistance(light.addedContentBg, light.addedBg)).toBeGreaterThanOrEqual(90); + expect(hexColorDistance(light.removedContentBg, light.removedBg)).toBeGreaterThanOrEqual(90); + }); + test("withTransparentBackground only swaps painted background fields", () => { const theme = resolveTheme("graphite", null); const transparent = withTransparentBackground(theme); diff --git a/src/ui/themes.ts b/src/ui/themes.ts index 975b7b6b..240a65eb 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -8,6 +8,7 @@ import { } from "./themes/catppuccin"; import { EMBER_THEME } from "./themes/ember"; import { GRAPHITE_THEME } from "./themes/graphite"; +import { HIGH_CONTRAST_DARK_THEME, HIGH_CONTRAST_LIGHT_THEME } from "./themes/highContrast"; import { MIDNIGHT_THEME } from "./themes/midnight"; import { PAPER_THEME } from "./themes/paper"; import { withLazySyntaxStyle } from "./themes/syntax"; @@ -29,6 +30,8 @@ export const THEMES: AppTheme[] = [ CATPPUCCIN_MACCHIATO_THEME, CATPPUCCIN_MOCHA_THEME, ZENBURN_THEME, + HIGH_CONTRAST_DARK_THEME, + HIGH_CONTRAST_LIGHT_THEME, ]; /** Return the built-in theme by id so config-defined themes can inherit from it. */ diff --git a/src/ui/themes/highContrast.ts b/src/ui/themes/highContrast.ts new file mode 100644 index 00000000..47e9f8d8 --- /dev/null +++ b/src/ui/themes/highContrast.ts @@ -0,0 +1,108 @@ +import { withLazySyntaxStyle } from "./syntax"; +import type { AppTheme } from "./types"; + +/** High-contrast dark theme with black surfaces and bright semantic accents. */ +export const HIGH_CONTRAST_DARK_THEME: AppTheme = withLazySyntaxStyle( + { + id: "high-contrast-dark", + label: "High Contrast Dark", + appearance: "dark", + background: "#000000", + panel: "#050505", + panelAlt: "#101010", + border: "#8a8a8a", + accent: "#ffffff", + accentMuted: "#707070", + text: "#ffffff", + muted: "#c4c4c4", + addedBg: "#00381e", + removedBg: "#4a0009", + movedAddedBg: "#003a52", + movedRemovedBg: "#3d145f", + contextBg: "#0b0b0b", + addedContentBg: "#008a45", + removedContentBg: "#a00016", + contextContentBg: "#171717", + addedSignColor: "#00ff66", + removedSignColor: "#ff5f6d", + lineNumberBg: "#000000", + lineNumberFg: "#d0d0d0", + selectedHunk: "#005f87", + badgeAdded: "#00ff66", + badgeRemoved: "#ff5f6d", + badgeNeutral: "#ffffff", + fileNew: "#00ff66", + fileDeleted: "#ff5f6d", + fileRenamed: "#ffd75f", + fileModified: "#d787ff", + fileUntracked: "#5fd7ff", + noteBorder: "#d787ff", + noteBackground: "#1e1028", + noteTitleBackground: "#3b1457", + noteTitleText: "#ffffff", + }, + { + default: "#ffffff", + keyword: "#5fd7ff", + string: "#87ff87", + comment: "#c4c4c4", + number: "#ffd75f", + function: "#ffffff", + property: "#87d7ff", + type: "#d787ff", + punctuation: "#e4e4e4", + }, +); + +/** High-contrast light theme with white surfaces and strong semantic colors. */ +export const HIGH_CONTRAST_LIGHT_THEME: AppTheme = withLazySyntaxStyle( + { + id: "high-contrast-light", + label: "High Contrast Light", + appearance: "light", + background: "#ffffff", + panel: "#fafafa", + panelAlt: "#f0f0f0", + border: "#000000", + accent: "#000000", + accentMuted: "#b0b0b0", + text: "#000000", + muted: "#404040", + addedBg: "#d7ffd7", + removedBg: "#ffd7d7", + movedAddedBg: "#d7f2ff", + movedRemovedBg: "#f0ddff", + contextBg: "#ffffff", + addedContentBg: "#8cff8c", + removedContentBg: "#ff8f8f", + contextContentBg: "#f5f5f5", + addedSignColor: "#006b1f", + removedSignColor: "#a00016", + lineNumberBg: "#f0f0f0", + lineNumberFg: "#202020", + selectedHunk: "#ffd400", + badgeAdded: "#006b1f", + badgeRemoved: "#a00016", + badgeNeutral: "#000000", + fileNew: "#006b1f", + fileDeleted: "#a00016", + fileRenamed: "#6f4d00", + fileModified: "#5f0087", + fileUntracked: "#005f87", + noteBorder: "#5f0087", + noteBackground: "#f1ddff", + noteTitleBackground: "#dcb8ff", + noteTitleText: "#000000", + }, + { + default: "#000000", + keyword: "#005f87", + string: "#006b1f", + comment: "#404040", + number: "#6f4d00", + function: "#000000", + property: "#005f87", + type: "#5f0087", + punctuation: "#404040", + }, +);