diff --git a/.changeset/everforest-palette-alignment.md b/.changeset/everforest-palette-alignment.md new file mode 100644 index 00000000..e6a0f585 --- /dev/null +++ b/.changeset/everforest-palette-alignment.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index eb682acf..19d541ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,12 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> ## theme guidance -- Built-in themes live in `src/ui/themes/.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. diff --git a/src/core/theme/catalog.ts b/src/core/theme/catalog.ts index e759568f..a648cbc1 100644 --- a/src/core/theme/catalog.ts +++ b/src/core/theme/catalog.ts @@ -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 = { andromeeda: "#23262e", "aurora-x": "#07090f", @@ -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" }, @@ -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 +> = { + "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 @@ -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; +} diff --git a/src/ui/themes.test.ts b/src/ui/themes.test.ts index 13105e45..bd0e49dc 100644 --- a/src/ui/themes.test.ts +++ b/src/ui/themes.test.ts @@ -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", diff --git a/src/ui/themes.ts b/src/ui/themes.ts index 28df368f..a7bf5424 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -9,6 +9,7 @@ import { getBundledShikiThemeBackground, getBundledShikiThemeDiffColors, getBundledShikiThemeForeground, + getBundledShikiThemeSurfaces, type BundledShikiThemeId, } from "../core/theme/catalog"; import type { AppTheme, SyntaxColors, ThemeBase } from "./themes/types"; @@ -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; @@ -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, @@ -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, @@ -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,