Merge pull request #69671 from NousResearch/bb/skin-owns-polarity

fix(ui-tui): a skin that authors a background owns its polarity
This commit is contained in:
brooklyn! 2026-07-22 18:17:36 -05:00 committed by GitHub
commit 8d6e045b8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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]