Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/everforest-palette-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Align the `everforest-dark` and `everforest-light` themes with the official Everforest palette: diff rows now use Everforest's real red/green/blue accents and `bg_red`/`bg_green`/`bg_blue` surfaces, and light mode regains visible word-level diff highlighting.
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,12 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer ->

## theme guidance

- Built-in themes live in `src/ui/themes/<theme-id>.ts`; register them in `src/ui/themes.ts` `THEMES` to control menu/cycle order.
- Built-in themes are table-driven from `src/core/theme/catalog.ts`: `BUNDLED_SHIKI_THEME_IDS`
(its order controls menu/cycle order) plus the background, foreground, diff-color, and
optional declared-surfaces tables; `src/ui/themes.ts` `buildShikiTheme` derives the full
`AppTheme` from those inputs.
- When a theme publishes official diff/UI surface tokens, declare them in
`BUNDLED_SHIKI_THEME_SURFACES` rather than approximating them with derived blends.
- When adding or renaming a built-in theme, update config validation, OpenTUI theme exports, docs/README examples, changelog, and tests that assert theme order.
- Keep official palette tokens separate from Hunk's semantic `AppTheme` mapping, and cover non-trivial derived colors with tests.

Expand Down
54 changes: 52 additions & 2 deletions src/core/theme/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,20 @@ export interface BundledShikiThemeDiffColors {
modified?: string;
}

export interface BundledShikiThemeSurfaces {
panel?: string;
panelAlt?: string;
border?: string;
addedBg?: string;
removedBg?: string;
movedBg?: string;
addedContentBg?: string;
removedContentBg?: string;
accentMuted?: string;
selectedHunk?: string;
lineNumberFg?: string;
}

export const BUNDLED_SHIKI_THEME_BACKGROUNDS: Record<BundledShikiThemeId, string> = {
andromeeda: "#23262e",
"aurora-x": "#07090f",
Expand Down Expand Up @@ -244,8 +258,8 @@ export const BUNDLED_SHIKI_THEME_DIFF_COLORS: Partial<
"catppuccin-mocha": { added: "#a6e3a1", removed: "#f38ba8", modified: "#f9e2af" },
dracula: { added: "#50fa7b", removed: "#ff5555", modified: "#8be9fd" },
"dracula-soft": { added: "#62e884", removed: "#ee6666", modified: "#97e1f1" },
"everforest-dark": { added: "#7a8c66", removed: "#a16366", modified: "#608986" },
"everforest-light": { added: "#b7c155", removed: "#fa9188", modified: "#83b9d0" },
"everforest-dark": { added: "#a7c080", removed: "#e67e80", modified: "#7fbbb3" },
"everforest-light": { added: "#8da101", removed: "#f85552", modified: "#3a94c5" },
"github-dark": { added: "#34d058", removed: "#ea4a5a", modified: "#79b8ff" },
"github-dark-default": { added: "#3fb950", removed: "#f85149", modified: "#d29922" },
"github-dark-dimmed": { added: "#57ab5a", removed: "#e5534b", modified: "#c69026" },
Expand Down Expand Up @@ -294,6 +308,36 @@ export const BUNDLED_SHIKI_THEME_DIFF_COLORS: Partial<
"vitesse-light": { added: "#1e754f", removed: "#ab5959", modified: "#296aa3" },
};

export const BUNDLED_SHIKI_THEME_SURFACES: Partial<
Record<BundledShikiThemeId, BundledShikiThemeSurfaces>
> = {
"everforest-dark": {
panel: "#343f44",
panelAlt: "#3d484d",
border: "#4f585e",
addedBg: "#3c4841",
removedBg: "#493b40",
movedBg: "#384b55",
addedContentBg: "#425047",
removedContentBg: "#514045",
accentMuted: "#3a515d",
selectedHunk: "#543a48",
lineNumberFg: "#9da9a0",
},
"everforest-light": {
panel: "#f4f0d9",
panelAlt: "#efebd4",
border: "#e0dcc7",
addedBg: "#f3f5d9",
removedBg: "#ffe7de",
movedBg: "#ecf5ed",
addedContentBg: "#f0f1d2",
removedContentBg: "#fde3da",
accentMuted: "#e9f0e9",
selectedHunk: "#eaedc8",
},
};

/** Return the editor surface declared by a bundled Shiki theme, when Hunk knows it. */
export function getBundledShikiThemeBackground(themeId: string | undefined) {
return themeId && themeId in BUNDLED_SHIKI_THEME_BACKGROUNDS
Expand All @@ -314,3 +358,9 @@ export function getBundledShikiThemeDiffColors(themeId: string | undefined) {
? BUNDLED_SHIKI_THEME_DIFF_COLORS[themeId as BundledShikiThemeId]
: undefined;
}

export function getBundledShikiThemeSurfaces(themeId: string | undefined) {
return themeId && themeId in BUNDLED_SHIKI_THEME_SURFACES
? BUNDLED_SHIKI_THEME_SURFACES[themeId as BundledShikiThemeId]
: undefined;
}
68 changes: 68 additions & 0 deletions src/ui/themes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,74 @@ describe("themes", () => {
}
});

test("keeps Everforest surfaces on official palette tokens", () => {
const dark = resolveTheme("everforest-dark", null);
expect(dark.background).toBe("#2d353b");
expect(dark.text).toBe("#d3c6aa");
expect(dark.panel).toBe("#343f44");
expect(dark.panelAlt).toBe("#3d484d");
expect(dark.border).toBe("#4f585e");
expect(dark.accent).toBe("#7fbbb3");
expect(dark.accentMuted).toBe("#3a515d");
expect(dark.addedBg).toBe("#3c4841");
expect(dark.removedBg).toBe("#493b40");
expect(dark.movedAddedBg).toBe("#384b55");
expect(dark.movedRemovedBg).toBe("#384b55");
expect(dark.addedContentBg).toBe("#425047");
expect(dark.removedContentBg).toBe("#514045");
expect(dark.addedSignColor).toBe("#a7c080");
expect(dark.removedSignColor).toBe("#e67e80");
expect(dark.selectedHunk).toBe("#543a48");
expect(dark.lineNumberFg).toBe("#9da9a0");
expect(dark.badgeAdded).toBe("#a7c080");
expect(dark.fileNew).toBe("#a7c080");
expect(dark.fileUntracked).toBe("#a7c080");
expect(dark.noteBorder).toBe("#7fbbb3");
expect(dark.noteBackground).toBe("#343f44");
expect(dark.noteTitleBackground).toBe("#343f44");

const light = resolveTheme("everforest-light", null);
expect(light.background).toBe("#fdf6e3");
expect(light.text).toBe("#5c6a72");
expect(light.panel).toBe("#f4f0d9");
expect(light.panelAlt).toBe("#efebd4");
expect(light.border).toBe("#e0dcc7");
expect(light.accent).toBe("#3a94c5");
expect(light.accentMuted).toBe("#e9f0e9");
expect(light.addedBg).toBe("#f3f5d9");
expect(light.removedBg).toBe("#ffe7de");
expect(light.movedAddedBg).toBe("#ecf5ed");
expect(light.movedRemovedBg).toBe("#ecf5ed");
expect(light.addedContentBg).toBe("#f0f1d2");
expect(light.removedContentBg).toBe("#fde3da");
expect(light.removedSignColor).toBe("#f85552");
expect(light.selectedHunk).toBe("#eaedc8");
expect(light.noteBorder).toBe("#3a94c5");
});

test("keeps Everforest word-level diff emphasis distinct from row tints", () => {
for (const theme of [
resolveTheme("everforest-dark", null),
resolveTheme("everforest-light", null),
]) {
expect(theme.addedBg).not.toBe(theme.addedContentBg);
expect(theme.removedBg).not.toBe(theme.removedContentBg);
expect(hexColorDistance(theme.addedContentBg, theme.contextBg)).toBeGreaterThan(
hexColorDistance(theme.addedBg, theme.contextBg),
);
expect(hexColorDistance(theme.removedContentBg, theme.contextBg)).toBeGreaterThan(
hexColorDistance(theme.removedBg, theme.contextBg),
);
}
});

test("keeps the Everforest dark deleted row red-dominant", () => {
const { removedBg } = resolveTheme("everforest-dark", null);
const red = Number.parseInt(removedBg.slice(1, 3), 16);
const blue = Number.parseInt(removedBg.slice(5, 7), 16);
expect(red).toBeGreaterThan(blue);
});

test("layers custom theme overrides on a bundled base", () => {
const custom = resolveTheme(
"custom",
Expand Down
82 changes: 34 additions & 48 deletions src/ui/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getBundledShikiThemeBackground,
getBundledShikiThemeDiffColors,
getBundledShikiThemeForeground,
getBundledShikiThemeSurfaces,
type BundledShikiThemeId,
} from "../core/theme/catalog";
import type { AppTheme, SyntaxColors, ThemeBase } from "./themes/types";
Expand Down Expand Up @@ -121,6 +122,7 @@ function buildShikiTheme(themeId: BundledShikiThemeId): AppTheme {
const editorBackground = getBundledShikiThemeBackground(themeId) ?? "#0d1117";
const editorForeground = getBundledShikiThemeForeground(themeId);
const diffColors = getBundledShikiThemeDiffColors(themeId);
const surfaces = getBundledShikiThemeSurfaces(themeId);
const isLightSurface = relativeLuminance(editorBackground) > 0.45;
const fallbackDiffColors = FALLBACK_DIFF_COLORS[isLightSurface ? "light" : "dark"];
const rowTint = isLightSurface ? 0.12 : 0.2;
Expand All @@ -130,14 +132,16 @@ function buildShikiTheme(themeId: BundledShikiThemeId): AppTheme {
const neutralPanel = blendHex(codeForeground, editorBackground, isLightSurface ? 0.04 : 0.08);
const neutralPanelAlt = blendHex(codeForeground, editorBackground, isLightSurface ? 0.08 : 0.12);
const neutralBorder = blendHex(codeForeground, editorBackground, isLightSurface ? 0.15 : 0.18);
const textForeground = readableForeground(editorForeground ?? codeForeground, neutralPanelAlt);
const panelSurface = surfaces?.panel ?? neutralPanel;
const panelAltSurface = surfaces?.panelAlt ?? neutralPanelAlt;
const textForeground = readableForeground(editorForeground ?? codeForeground, panelAltSurface);
const lineNumberForeground = readableDimForeground(
blendHex(textForeground, editorBackground, 0.56),
surfaces?.lineNumberFg ?? blendHex(textForeground, editorBackground, 0.56),
editorBackground,
);
const mutedForeground = readableDimForeground(
blendHex(textForeground, editorBackground, 0.56),
neutralPanelAlt,
panelAltSurface,
);
const addedSignColor = readableDiffSign(
diffColors?.added ?? fallbackDiffColors.added,
Expand All @@ -151,54 +155,36 @@ function buildShikiTheme(themeId: BundledShikiThemeId): AppTheme {
diffColors?.modified ?? fallbackDiffColors.modified,
editorBackground,
);
const addedBg = readableTintedBackground(
addedSignColor,
editorBackground,
textForeground,
rowTint,
);
const removedBg = readableTintedBackground(
removedSignColor,
editorBackground,
textForeground,
rowTint,
);
const movedBg = readableTintedBackground(
modifiedColor,
editorBackground,
textForeground,
rowTint,
);
const addedContentBg = readableTintedBackground(
addedSignColor,
editorBackground,
textForeground,
contentTint,
);
const removedContentBg = readableTintedBackground(
removedSignColor,
editorBackground,
textForeground,
contentTint,
);
const accentMuted = readableTintedBackground(
modifiedColor,
editorBackground,
textForeground,
selectedTint,
);
const addedBg =
surfaces?.addedBg ??
readableTintedBackground(addedSignColor, editorBackground, textForeground, rowTint);
const removedBg =
surfaces?.removedBg ??
readableTintedBackground(removedSignColor, editorBackground, textForeground, rowTint);
const movedBg =
surfaces?.movedBg ??
readableTintedBackground(modifiedColor, editorBackground, textForeground, rowTint);
const addedContentBg =
surfaces?.addedContentBg ??
readableTintedBackground(addedSignColor, editorBackground, textForeground, contentTint);
const removedContentBg =
surfaces?.removedContentBg ??
readableTintedBackground(removedSignColor, editorBackground, textForeground, contentTint);
const accentMuted =
surfaces?.accentMuted ??
readableTintedBackground(modifiedColor, editorBackground, textForeground, selectedTint);
const syntaxColors = buildSyntaxColors(textForeground);
const badgeAdded = readableChromeColor(addedSignColor, neutralPanel, neutralPanelAlt);
const badgeRemoved = readableChromeColor(removedSignColor, neutralPanel, neutralPanelAlt);
const badgeModified = readableChromeColor(modifiedColor, neutralPanel, neutralPanelAlt);
const badgeAdded = readableChromeColor(addedSignColor, panelSurface, panelAltSurface);
const badgeRemoved = readableChromeColor(removedSignColor, panelSurface, panelAltSurface);
const badgeModified = readableChromeColor(modifiedColor, panelSurface, panelAltSurface);
const themeBase: ThemeBase = {
id: themeId,
label: themeId,
appearance: isLightSurface ? "light" : "dark",
background: editorBackground,
panel: neutralPanel,
panelAlt: neutralPanelAlt,
border: neutralBorder,
panel: panelSurface,
panelAlt: panelAltSurface,
border: surfaces?.border ?? neutralBorder,
accent: modifiedColor,
accentMuted,
text: textForeground,
Expand All @@ -215,10 +201,10 @@ function buildShikiTheme(themeId: BundledShikiThemeId): AppTheme {
removedSignColor,
lineNumberBg: editorBackground,
lineNumberFg: lineNumberForeground,
selectedHunk: blendHex(modifiedColor, editorBackground, selectedTint),
noteBackground: neutralPanel,
selectedHunk: surfaces?.selectedHunk ?? blendHex(modifiedColor, editorBackground, selectedTint),
noteBackground: panelSurface,
noteBorder: modifiedColor,
noteTitleBackground: neutralPanel,
noteTitleBackground: panelSurface,
noteTitleText: textForeground,
badgeAdded,
badgeRemoved,
Expand Down