Merge PR #2519: classify HTML export theme defaults by status-line luminance

This commit is contained in:
can1357
2026-06-14 03:32:54 +02:00
3 changed files with 68 additions and 12 deletions
+4
View File
@@ -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
+27 -11
View File
@@ -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<Record<string, string>> {
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<string, string> = {};
@@ -2560,8 +2581,9 @@ export async function getResolvedThemeColors(themeName?: string): Promise<Record
}
/**
* Check if a theme is a "light" theme by analyzing its background color luminance.
* Loads theme JSON synchronously (built-in or custom file) and resolves userMessageBg.
* Check if a theme is a "light" theme by analyzing its status-line background
* luminance. Loads theme JSON synchronously (built-in or custom file on disk)
* for callers in synchronous flows (settings migration, setup wizard).
*/
export function isLightTheme(themeName?: string): boolean {
const name = themeName ?? "dark";
@@ -2578,13 +2600,7 @@ export function isLightTheme(themeName?: string): boolean {
return false;
}
}
try {
const resolved = resolveVarRefs(themeJson.colors.userMessageBg, themeJson.vars ?? {});
const luminance = colorLuma(resolved);
return luminance !== undefined && luminance > 0.5;
} catch {
return false;
}
return isLightThemeJson(themeJson);
}
/**
@@ -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");
});
});