fix(ui-tui): a skin that authors a background owns its polarity

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-22 18:11:27 -05:00
parent 54aaff142e
commit e762ea1741
3 changed files with 70 additions and 8 deletions

View File

@ -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',

View File

@ -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 ?? {})

View File

@ -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]