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:
parent
54aaff142e
commit
e762ea1741
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 ?? {})
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue