diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 20edecc85..7a4c86ec0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -80,6 +80,10 @@ - Added the `mnemopi.embeddingVariant` setting (`en` | `multilingual`) selecting a stronger SOTA local embedding model — `en` → `BAAI/bge-base-en-v1.5` (768d), `multilingual` → `intfloat/multilingual-e5-large` (1024d). Resolution precedence is `mnemopi.embeddingModel` setting > `MNEMOPI_EMBEDDING_MODEL` env > variant default, so the documented env override is still honored. Changing the active model wipes and rebuilds stored embeddings on the next writable start ([#2476](https://github.com/can1357/oh-my-pi/issues/2476)) +### Fixed + +- Fixed HTML session export rendering empty text tokens (`text`, `userMessageText`, `customMessageText`, `toolTitle`) as the dark-theme grey `#e5e5e7` on every theme not literally named `light`, making transcripts illegible on custom light themes like `sandstone`, `limestone`, and `porcelain`. `getResolvedThemeColors` and the standalone `isLightTheme` helper now classify against the resolved `statusLineBg` luminance (the same surface `Theme.isLight` uses), so the HTML `defaultText` falls back to `#000000` on light themes and the standalone helper stays in lockstep with `Theme.isLight` ([#2516](https://github.com/can1357/oh-my-pi/issues/2516)). + ## [15.12.5] - 2026-06-13 ### Changed diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 72ea5b306..d65f9c5b3 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -2532,17 +2532,38 @@ function ansi256ToHex(index: number): string { return `#${grayHex}${grayHex}${grayHex}`; } +/** + * Classify a parsed theme JSON as light/dark by the perceived luminance of its + * status-line background. Mirrors {@link Theme.isLight} so the synchronous + * helpers below stay in lockstep with the runtime classifier — see the comment + * on `Theme.statusLineLuminance` for why `statusLineBg` is the source of truth + * (themes like `porcelain` style a dark chat bubble on an otherwise-light + * theme, so `userMessageBg` is unreliable). + */ +function isLightThemeJson(themeJson: ThemeJson): boolean { + try { + const resolved = resolveVarRefs(themeJson.colors.statusLineBg, themeJson.vars ?? {}); + const luminance = colorLuma(resolved); + return luminance !== undefined && luminance > 0.5; + } catch { + return false; + } +} + /** * Get resolved theme colors as CSS-compatible hex strings. * Used by HTML export to generate CSS custom properties. */ export async function getResolvedThemeColors(themeName?: string): Promise> { const name = themeName ?? getDefaultTheme(); - const isLight = name === "light"; const themeJson = await loadThemeJson(name); + const isLight = isLightThemeJson(themeJson); const resolved = resolveThemeColors(themeJson.colors, themeJson.vars); - // Default text color for empty values (terminal uses default fg color) + // Default text color for empty values (terminal uses default fg color). + // Must follow the actual theme appearance — hardcoding `name === "light"` + // makes every custom light theme (sandstone, limestone, porcelain, …) fall + // through to the dark-theme grey and renders the HTML export illegible. const defaultText = isLight ? "#000000" : "#e5e5e7"; const cssColors: Record = {}; @@ -2560,8 +2581,9 @@ export async function getResolvedThemeColors(themeName?: string): Promise 0.5; - } catch { - return false; - } + return isLightThemeJson(themeJson); } /** diff --git a/packages/coding-agent/test/theme-islight.test.ts b/packages/coding-agent/test/theme-islight.test.ts index 7e5af4b10..5cf282fea 100644 --- a/packages/coding-agent/test/theme-islight.test.ts +++ b/packages/coding-agent/test/theme-islight.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getResolvedThemeColors, getThemeByName, isLightTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; describe("Theme.isLight", () => { it("classifies built-in themes by their status-line surface", async () => { @@ -20,3 +20,39 @@ describe("Theme.isLight", () => { expect(dark?.accentSurfaceLuminance).toBeUndefined(); }); }); + +describe("isLightTheme (standalone)", () => { + // Regression for #2516: the standalone helper used to classify on + // userMessageBg, mismatching Theme.isLight (statusLineBg) and the HTML + // export's defaultText. porcelain is the canonical mismatch (dark bubble, + // light status line); sandstone/limestone exercise the custom-light path. + it.each([ + ["sandstone", true], + ["limestone", true], + ["porcelain", true], + ["light", true], + ["dark", false], + ["dark-catppuccin", false], + ])("classifies %s as isLight=%s", (name, expected) => { + expect(isLightTheme(name)).toBe(expected); + }); +}); + +describe("getResolvedThemeColors HTML export defaults", () => { + // Regression for #2516: empty color tokens fell back to #e5e5e7 (the + // dark-theme grey) for every theme not literally named "light", making the + // session transcript text illegible on every custom light theme. + it("uses near-black for empty text tokens on light themes", async () => { + const colors = await getResolvedThemeColors("sandstone"); + expect(colors.text).toBe("#000000"); + expect(colors.userMessageText).toBe("#000000"); + expect(colors.customMessageText).toBe("#000000"); + expect(colors.toolTitle).toBe("#000000"); + }); + + it("uses light grey for empty text tokens on dark themes", async () => { + const colors = await getResolvedThemeColors("dark"); + expect(colors.text).toBe("#e5e5e7"); + expect(colors.userMessageText).toBe("#e5e5e7"); + }); +});