diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index ad54c285fa64..fb7f7867025a 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -302,6 +302,42 @@ describe('fromSkin', () => { expect(theme.color.prompt).toBe('ansi256(136)') }) + // ── A skin that authors a background OWNS its polarity ────────────── + // The TUI paints the terminal with the skin's background (OSC-11), so + // every adaptation pass must run against the skin's canvas, not the host + // profile the skin just covered. The real-world failure: a pure-black + // skin on light-mode Apple Terminal got its text ansi256-bucketed for a + // light background that no longer exists — invisible on the painted black. + + it('a dark-background skin on light Apple Terminal keeps its truecolor text (no light-mode bucketing)', async () => { + const { fromSkin } = await importThemeWithEnv({ TERM_PROGRAM: 'Apple_Terminal' }) + + const theme = fromSkin({ background: '#000000', ui_accent: '#ff9e18', ui_text: '#ffa726' }, {}) + + expect(theme.color.text).toBe('#ffa726') + expect(theme.color.prompt).not.toMatch(/^ansi256/) + }) + + it('a skin background outranks the cached host background for adaptation and tone derivation', async () => { + const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' }) + + const theme = fromSkin({ background: '#000000', ui_text: '#ffa726' }, {}) + + // Text is not contrast-lifted toward a white host it painted over… + expect(theme.color.text).toBe('#ffa726') + // …and derived fills mix against the skin's black, not the host's white. + expect(luminance(theme.color.completionBg)).toBeLessThanOrEqual(0.35) + expect(luminance(theme.color.statusBg)).toBeLessThanOrEqual(0.35) + }) + + it('skinIsLight: the authored background decides; host detection only when absent', async () => { + const { skinIsLight } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' }) + + expect(skinIsLight({ background: '#000000' })).toBe(false) + expect(skinIsLight({ background: '#f5f5f5' })).toBe(true) + expect(skinIsLight({})).toBe(true) // no canvas of its own → host polarity + }) + it('keeps truecolor light Apple Terminal in truecolor (adapting, not ansi256-bucketing)', async () => { const { contrastRatio, fromSkin } = await importThemeWithEnv({ COLORTERM: 'truecolor', diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index ec0fb02748ba..fccb499b0e4e 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -21,7 +21,7 @@ import { topLevelSubagents } from '../lib/subagentTree.js' import { isPaintableHex, setTerminalBackground, setTerminalForeground } from '../lib/terminalModes.js' import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js' import { bootSeededPin, invalidateBootBackground, writeBootTheme } from '../lib/themeBoot.js' -import { defaultThemeForCurrentBackground, detectLightMode, fromSkin, type Theme } from '../theme.js' +import { defaultThemeForCurrentBackground, fromSkin, skinIsLight, type Theme } from '../theme.js' import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' @@ -45,8 +45,10 @@ const themeForSkin = (s: GatewaySkin) => { // can ship a fills-only `light_colors` (flip the dark navy menu/status fills // to light on a light terminal) while its vivid foreground golds keep coming // from `colors` and render raw through fromSkin's shim. A full paired block - // still works — it just overrides every key it lists. - const paired = detectLightMode() ? s.light_colors : s.dark_colors + // still works — it just overrides every key it lists. Polarity follows the + // skin's authored background when it has one (the skin paints the terminal + // with it), else the host's. + const paired = skinIsLight(s.colors ?? {}) ? s.light_colors : s.dark_colors const colors = paired && Object.keys(paired).length ? { ...(s.colors ?? {}), ...paired } : (s.colors ?? {}) diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index bfd777a2f0c3..7ddd75a4e61d 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -788,6 +788,28 @@ export function defaultThemeForCurrentBackground(env: NodeJS.ProcessEnv = proces // ── Skin → Theme ───────────────────────────────────────────────────── +/** The skin's authored canvas as a #-prefixed hex, or null when absent/junk. */ +const authoredBackground = (raw: string | undefined): null | string => { + const v = (raw ?? '').trim() + + return backgroundLuminance(v) === null ? null : v.startsWith('#') ? v : `#${v}` +} + +/** + * A skin that authors a background OWNS its polarity: the TUI paints the + * terminal with it (applySkin → OSC-11), so contrast adaptation, the derived + * tone ladder, and ANSI light-terminal normalization must all run against the + * skin's canvas — not the host profile the skin just covered. (A pure-black + * skin on a light Apple Terminal otherwise gets its text remapped for a light + * background it painted over: invisible.) Host detection still governs skins + * without a background — they render on the terminal's own surface. + */ +export function skinIsLight(colors: SkinColors, env: NodeJS.ProcessEnv = process.env): boolean { + const authored = backgroundLuminance(colors['background'] ?? '') + + return authored === null ? detectLightMode(env) : authored >= LUMA_LIGHT_THRESHOLD +} + export function fromSkin( colors: SkinColors, branding: SkinBranding, @@ -796,11 +818,13 @@ export function fromSkin( toolPrefix = '', helpHeader = '' ): Theme { - // Live detection (not the module-load snapshot): by the time the gateway - // skin arrives, the OSC-11 background probe has usually answered and cached - // itself into HERMES_TUI_BACKGROUND. See #applySkin / syncThemeToTerminalBackground. - const isLight = detectLightMode() - const bg = referenceBackground(isLight) + // Polarity: the skin's own canvas when it authors one (see skinIsLight); + // otherwise live host detection (not the module-load snapshot — by the time + // the gateway skin arrives, the OSC-11 probe has usually answered and cached + // itself into HERMES_TUI_BACKGROUND. See #applySkin / syncThemeToTerminalBackground). + const skinBg = authoredBackground(colors['background']) + const isLight = skinIsLight(colors) + const bg = skinBg ?? referenceBackground(isLight) const base = isLight ? LIGHT_SEEDS : DARK_SEEDS const d = isLight ? LIGHT_THEME : DARK_THEME const c = (k: string) => colors[k]