From e762ea1741a06c26b2111d3a1021a7b1ba80a207 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 18:11:27 -0500 Subject: [PATCH] fix(ui-tui): a skin that authors a background owns its polarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme engine picked light/dark adaptation from the HOST terminal (detectLightMode) even when the skin authors its own background — which the TUI then paints onto the terminal via OSC-11. On a light-mode Apple Terminal without truecolor, a pure-black skin (e.g. Bloomberg) got its foregrounds ansi256-bucketed *for a light background that no longer exists*: theme.color.text became 'ansi256(214)', which also fails the OSC-10 hex gate, so the terminal default fg stayed the light profile's near-black — markdown body text rendered black-on-black. fromSkin now resolves polarity from the skin's authored background when present (skinIsLight), uses that background as the reference canvas for the derived tone ladder and contrast adaptation, and only falls back to host detection for skins without a background (they render on the terminal's own surface). The paired light_colors/dark_colors pick in themeForSkin follows the same rule. --- ui-tui/src/__tests__/theme.test.ts | 36 +++++++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 8 +++-- ui-tui/src/theme.ts | 34 ++++++++++++++++--- 3 files changed, 70 insertions(+), 8 deletions(-) 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]