mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
fix(ui-tui): light mode renders the vivid palette RAW, not WCAG-darkened
Pixel-sampled against the reference screenshots: the beloved classic light-mode look is the vivid authored golds rendered essentially raw (#FFD700 shows as bright #F5C242) — transparent terminal profiles apply no contrast lift of their own, and pre-darkening every foreground to WCAG 4.5 produced the reported mustard mud. Light-mode display floors become near-invisible rescues only (1.18 display / 1.6 semantic; dark keeps 1.45/2.2); the default skin ships a fills-only light_colors OVERLAY (polarity-flip the navy menu/status fills, foregrounds inherit the vivid colors), and themeForSkin merges polarity overlays instead of replacing. Palette audit reworked: base colors audited fully, overlays for valid keys and fill polarity.
This commit is contained in:
parent
296303302d
commit
28e05a3c85
5 changed files with 144 additions and 82 deletions
|
|
@ -215,35 +215,21 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
|||
"shell_dollar": "#4dabf7",
|
||||
"voice_status_bg": "#1a1a2e",
|
||||
},
|
||||
# Hand-tuned light palette (mirrors the TUI's LIGHT_THEME golds).
|
||||
# No paired light_colors. On a light (in practice transparent) terminal
|
||||
# the beloved classic look is these vivid golds rendered RAW — xterm
|
||||
# applies no contrast lift over a transparent bg, so #FFD700 shows as
|
||||
# bright #F5C242, not a WCAG-darkened mustard. The TUI's display shim
|
||||
# only flips fills to light polarity and rescues genuinely-invisible
|
||||
# near-white text; the golds pass through untouched. A hand-authored
|
||||
# dark "light palette" here is exactly what made it read as mud.
|
||||
"light_colors": {
|
||||
"banner_border": "#7A4F1F",
|
||||
"banner_title": "#8B6914",
|
||||
"banner_accent": "#A0651C",
|
||||
"banner_dim": "#7A5A0F",
|
||||
"banner_text": "#3D2F13",
|
||||
"ui_accent": "#A0651C",
|
||||
"ui_label": "#7A5A0F",
|
||||
"ui_ok": "#2E7D32",
|
||||
"ui_error": "#C62828",
|
||||
"ui_warn": "#E65100",
|
||||
"prompt": "#2B2014",
|
||||
"input_rule": "#7A4F1F",
|
||||
"response_border": "#8B6914",
|
||||
"status_bar_bg": "#F5F5F5",
|
||||
"status_bar_text": "#333333",
|
||||
"status_bar_strong": "#8B6914",
|
||||
"status_bar_dim": "#8A8A8A",
|
||||
"status_bar_good": "#2E7D32",
|
||||
"status_bar_warn": "#8B6914",
|
||||
"status_bar_bad": "#D84315",
|
||||
"status_bar_critical": "#B71C1C",
|
||||
"session_label": "#7A5A0F",
|
||||
"session_border": "#7A5A0F",
|
||||
# Fills only: on a light terminal the dark navy menu/status fills
|
||||
# must flip to light. Foregrounds intentionally inherit the vivid
|
||||
# `colors` above so they render raw.
|
||||
"completion_menu_bg": "#F5F5F5",
|
||||
"completion_menu_current_bg": "#E0D1BF",
|
||||
"selection_bg": "#D4E4F7",
|
||||
"shell_dollar": "#1565C0",
|
||||
"status_bar_bg": "#F5F5F5",
|
||||
"voice_status_bg": "#F5F5F5",
|
||||
},
|
||||
"spinner": {
|
||||
|
|
|
|||
|
|
@ -126,41 +126,57 @@ def contrast(a: str, b: str) -> float:
|
|||
LIGHT_AUTHORED = frozenset({"daylight", "warm-lightmode"})
|
||||
|
||||
|
||||
def _palettes():
|
||||
"""Yield (skin, palette_name, palette, is_light) for every built-in."""
|
||||
# A `light_colors`/`dark_colors` block is an OVERLAY on `colors`, not a full
|
||||
# replacement — a skin may ship a fills-only light overlay (flip the dark navy
|
||||
# menu/status fills to light) while its vivid foregrounds keep coming from
|
||||
# `colors` and render raw. So only the base `colors` block is held to the
|
||||
# completeness + full foreground-contrast contract; overlays are audited for
|
||||
# valid keys and fill polarity only.
|
||||
def _base_palettes():
|
||||
"""Yield (skin, palette, is_light) for every built-in's base `colors`."""
|
||||
for name, skin in _BUILTIN_SKINS.items():
|
||||
yield name, "colors", skin.get("colors", {}), name in LIGHT_AUTHORED
|
||||
yield name, skin.get("colors", {}), name in LIGHT_AUTHORED
|
||||
|
||||
|
||||
def _overlays():
|
||||
"""Yield (skin, block, palette, is_light) for every partial overlay block."""
|
||||
for name, skin in _BUILTIN_SKINS.items():
|
||||
if skin.get("light_colors"):
|
||||
yield name, "light_colors", skin["light_colors"], True
|
||||
if skin.get("dark_colors"):
|
||||
yield name, "dark_colors", skin["dark_colors"], False
|
||||
|
||||
|
||||
ALL_PALETTES = list(_palettes())
|
||||
PALETTE_IDS = [f"{skin}:{block}" for skin, block, _, _ in ALL_PALETTES]
|
||||
BASE_PALETTES = list(_base_palettes())
|
||||
BASE_IDS = [f"{skin}:colors" for skin, _, _ in BASE_PALETTES]
|
||||
OVERLAYS = list(_overlays())
|
||||
OVERLAY_IDS = [f"{skin}:{block}" for skin, block, _, _ in OVERLAYS]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), ALL_PALETTES, ids=PALETTE_IDS)
|
||||
def test_palette_is_complete(skin, block, palette, is_light):
|
||||
@pytest.mark.parametrize(("skin", "palette", "is_light"), BASE_PALETTES, ids=BASE_IDS)
|
||||
def test_base_palette_is_complete(skin, palette, is_light):
|
||||
missing = REQUIRED_KEYS - palette.keys()
|
||||
assert not missing, f"{skin}.{block} missing keys: {sorted(missing)}"
|
||||
assert not missing, f"{skin}.colors missing keys: {sorted(missing)}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), ALL_PALETTES, ids=PALETTE_IDS)
|
||||
def test_palette_contrast_and_polarity(skin, block, palette, is_light):
|
||||
@pytest.mark.parametrize(("skin", "palette", "is_light"), BASE_PALETTES, ids=BASE_IDS)
|
||||
def test_base_palette_contrast_and_polarity(skin, palette, is_light):
|
||||
pole = LIGHT_POLE if is_light else DARK_POLE
|
||||
problems = []
|
||||
|
||||
for key in STRONG_FG:
|
||||
ratio = contrast(palette[key], pole)
|
||||
if ratio < STRONG_MIN:
|
||||
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {STRONG_MIN} vs {pole}")
|
||||
# Light-authored bases render on a light pole where the classic look is the
|
||||
# vivid palette rendered RAW (transparent terminals apply no lift), so the
|
||||
# firm STRONG/SOFT floors only apply to dark-authored bases on a dark pole.
|
||||
if not is_light:
|
||||
for key in STRONG_FG:
|
||||
ratio = contrast(palette[key], pole)
|
||||
if ratio < STRONG_MIN:
|
||||
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {STRONG_MIN} vs {pole}")
|
||||
|
||||
for key in SOFT_FG:
|
||||
ratio = contrast(palette[key], pole)
|
||||
if ratio < SOFT_MIN:
|
||||
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {SOFT_MIN} vs {pole}")
|
||||
for key in SOFT_FG:
|
||||
ratio = contrast(palette[key], pole)
|
||||
if ratio < SOFT_MIN:
|
||||
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {SOFT_MIN} vs {pole}")
|
||||
|
||||
status_bg = palette["status_bar_bg"]
|
||||
for key in ON_STATUS_BAR:
|
||||
|
|
@ -169,13 +185,35 @@ def test_palette_contrast_and_polarity(skin, block, palette, is_light):
|
|||
if ratio < floor:
|
||||
problems.append(f"{key}={palette[key]} contrast {ratio:.2f} < {floor} vs status_bar_bg {status_bg}")
|
||||
|
||||
for key in FILLS:
|
||||
_check_fills(palette, is_light, FILLS, problems)
|
||||
_check_chip(palette, problems)
|
||||
|
||||
assert not problems, f"{skin}.colors:\n " + "\n ".join(problems)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("skin", "block", "palette", "is_light"), OVERLAYS, ids=OVERLAY_IDS)
|
||||
def test_overlay_keys_and_fill_polarity(skin, block, palette, is_light):
|
||||
unknown = palette.keys() - REQUIRED_KEYS
|
||||
assert not unknown, f"{skin}.{block} has unknown keys: {sorted(unknown)}"
|
||||
|
||||
problems = []
|
||||
_check_fills(palette, is_light, [k for k in FILLS if k in palette], problems)
|
||||
if "completion_menu_current_bg" in palette and "completion_menu_bg" in palette:
|
||||
_check_chip(palette, problems)
|
||||
|
||||
assert not problems, f"{skin}.{block}:\n " + "\n ".join(problems)
|
||||
|
||||
|
||||
def _check_fills(palette, is_light, keys, problems):
|
||||
for key in keys:
|
||||
lum = luminance(palette[key])
|
||||
if is_light and lum < 0.4:
|
||||
problems.append(f"{key}={palette[key]} is a dark fill (lum {lum:.2f}) in a light palette")
|
||||
if not is_light and lum > 0.35:
|
||||
problems.append(f"{key}={palette[key]} is a light fill (lum {lum:.2f}) in a dark palette")
|
||||
|
||||
|
||||
def _check_chip(palette, problems):
|
||||
# The selection chip must remain distinguishable from the menu surface.
|
||||
chip = contrast(palette["completion_menu_current_bg"], palette["completion_menu_bg"])
|
||||
if chip < 1.15:
|
||||
|
|
@ -183,5 +221,3 @@ def test_palette_contrast_and_polarity(skin, block, palette, is_light):
|
|||
f"completion_menu_current_bg={palette['completion_menu_current_bg']} "
|
||||
f"indistinguishable from completion_menu_bg (contrast {chip:.2f})"
|
||||
)
|
||||
|
||||
assert not problems, f"{skin}.{block}:\n " + "\n ".join(problems)
|
||||
|
|
|
|||
|
|
@ -307,9 +307,11 @@ describe('fromSkin', () => {
|
|||
const theme = fromSkin({ banner_text: '#FFF8DC' }, {})
|
||||
|
||||
// No ansi256 bucketing on truecolor terminals — a truly invisible cream
|
||||
// (1.08:1 on white) still gets the display shim's gentle rescue.
|
||||
// (1.08:1 on white) still gets the display shim's gentle light-mode rescue
|
||||
// (floor 1.18: enough to make near-white text appear, not enough to crush
|
||||
// the vivid golds into mud).
|
||||
expect(theme.color.text).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
expect(contrastRatio(theme.color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.45)
|
||||
expect(contrastRatio(theme.color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.18)
|
||||
})
|
||||
|
||||
it('normalizes Apple Terminal names before matching', async () => {
|
||||
|
|
@ -398,8 +400,10 @@ describe('derived tone ladder', () => {
|
|||
[dark.DARK_THEME.color.completionBg, '#1a1a2e', 'dark surface'],
|
||||
[dark.DARK_THEME.color.completionCurrentBg, '#333355', 'dark chip'],
|
||||
[dark.DARK_THEME.color.selectionBg, '#3a3a55', 'dark selection'],
|
||||
[light.LIGHT_THEME.color.muted, '#7A5A0F', 'light muted'],
|
||||
[light.LIGHT_THEME.color.statusFg, '#333333', 'light statusFg'],
|
||||
// Light canon = liftForContrast(dark literal, white, 4.5): the exact
|
||||
// colors xterm's minimumContrastRatio rendered on light hosts.
|
||||
[light.LIGHT_THEME.color.muted, '#946C08', 'light muted'],
|
||||
[light.LIGHT_THEME.color.statusFg, '#6F6F6F', 'light statusFg'],
|
||||
[light.LIGHT_THEME.color.completionBg, '#F5F5F5', 'light surface'],
|
||||
[light.LIGHT_THEME.color.completionCurrentBg, '#e0d1bf', 'light chip'],
|
||||
[light.LIGHT_THEME.color.selectionBg, '#D4E4F7', 'light selection']
|
||||
|
|
@ -457,14 +461,17 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => {
|
|||
expect(color.accent.toLowerCase()).toBe('#7eb8f6')
|
||||
expect(color.muted.toLowerCase()).toBe('#4b5563')
|
||||
|
||||
// Colors below the display floor get xterm's hue-preserving rescue.
|
||||
// Light mode renders the authored palette essentially RAW: a transparent
|
||||
// terminal (the common Cursor case) applies no contrast lift of its own,
|
||||
// and the beloved classic look is the vivid palette, not a WCAG-darkened
|
||||
// one. Foregrounds only clear the near-invisible floor (1.18).
|
||||
for (const key of ['text', 'prompt', 'accent', 'label', 'primary', 'muted', 'border'] as const) {
|
||||
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.45)
|
||||
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.18)
|
||||
}
|
||||
|
||||
// Semantic alert colors carry meaning — firmer floor.
|
||||
// Semantic alert colors carry meaning — firmer floor, still gentle on light.
|
||||
for (const key of ['ok', 'error', 'warn', 'statusGood', 'statusCritical'] as const) {
|
||||
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(2.15)
|
||||
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.6)
|
||||
}
|
||||
|
||||
// Background roles the skin never defined must be light-polarity fills,
|
||||
|
|
@ -480,7 +487,7 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => {
|
|||
const { color } = fromSkin({ banner_text: '#FFF8DC' }, {})
|
||||
|
||||
expect(color.text.toLowerCase()).not.toBe('#fff8dc')
|
||||
expect(contrastRatio(color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.45)
|
||||
expect(contrastRatio(color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.18)
|
||||
|
||||
// Multiplicative lift preserves channel ordering (warm stays warm).
|
||||
const [r, g, b] = [1, 3, 5].map(i => parseInt(color.text.slice(i, i + 2), 16))
|
||||
|
|
|
|||
|
|
@ -39,11 +39,15 @@ const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready')
|
|||
let lastSkin: GatewaySkin | null = null
|
||||
|
||||
const themeForSkin = (s: GatewaySkin) => {
|
||||
// Prefer the skin's hand-tuned palette for the terminal's polarity
|
||||
// (desktop colors/darkColors contract). Without a paired block, `colors`
|
||||
// goes through fromSkin's automatic contrast adaptation instead.
|
||||
// Polarity overrides OVERLAY the base palette, they don't replace it: a skin
|
||||
// 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
|
||||
const colors = paired && Object.keys(paired).length ? paired : (s.colors ?? {})
|
||||
|
||||
const colors =
|
||||
paired && Object.keys(paired).length ? { ...(s.colors ?? {}), ...paired } : (s.colors ?? {})
|
||||
|
||||
return fromSkin(colors, s.branding ?? {}, s.banner_logo ?? '', s.banner_hero ?? '', s.tool_prefix ?? '', s.help_header ?? '')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -340,21 +340,28 @@ export const DARK_SEEDS: ThemeSeeds = {
|
|||
}
|
||||
|
||||
// Light-terminal seeds: darker golds/ambers that stay legible on white.
|
||||
// The classic light-mode Hermes look was never hand-authored: for years the
|
||||
// TUI emitted the DARK golds and hosts with xterm's minimumContrastRatio
|
||||
// (Cursor defaults to 4.5) lifted them against white — hue and saturation
|
||||
// kept, luminance clamped. These seeds are those exact lifts
|
||||
// (liftForContrast(dark, '#ffffff', 4.5)), so hosts WITHOUT a contrast pass
|
||||
// render the same thing Cursor always showed. Text/prompt stay ink — body
|
||||
// copy historically rendered in the terminal's default near-black fg.
|
||||
export const LIGHT_SEEDS: ThemeSeeds = {
|
||||
accent: '#A0651C',
|
||||
accent: '#956E00',
|
||||
bg: '#ffffff',
|
||||
border: '#7A4F1F',
|
||||
error: '#C62828',
|
||||
ok: '#2E7D32',
|
||||
primary: '#8B6914',
|
||||
border: '#A56628',
|
||||
error: '#C14240',
|
||||
ok: '#367E39',
|
||||
primary: '#867000',
|
||||
prompt: '#2B2014',
|
||||
shellDollar: '#1565C0',
|
||||
statusBad: '#D84315',
|
||||
statusCritical: '#B71C1C',
|
||||
statusGood: '#2E7D32',
|
||||
statusWarn: '#8B6914',
|
||||
shellDollar: '#377BB3',
|
||||
statusBad: '#A65A00',
|
||||
statusCritical: '#B94D4D',
|
||||
statusGood: '#5C7A5C',
|
||||
statusWarn: '#867000',
|
||||
text: '#3D2F13',
|
||||
warn: '#E65100'
|
||||
warn: '#956115'
|
||||
}
|
||||
|
||||
export const DARK_THEME: Theme = {
|
||||
|
|
@ -401,8 +408,20 @@ export const LIGHT_THEME: Theme = {
|
|||
// The lift itself is xterm.js's own multiplicative algorithm
|
||||
// (liftForContrast), so on hosts that run their own minimumContrastRatio the
|
||||
// two adjustments agree instead of fighting.
|
||||
// Foreground floors are polarity-aware. On a DARK background the authored
|
||||
// palette is already bright, so a modest floor only rescues the rare dark
|
||||
// tone. On a LIGHT background — which in practice means a TRANSPARENT Cursor/
|
||||
// terminal window compositing over a light editor, where xterm applies NO
|
||||
// contrast lift of its own (there is no solid bg to measure against) — the
|
||||
// beloved classic look is the authored palette rendered essentially RAW:
|
||||
// vivid #FFD700 gold (~1.36:1), not a WCAG-darkened mustard. So the light
|
||||
// floor is a near-invisible rescue only (catches cream #FFF8DC at 1.08 but
|
||||
// leaves the golds untouched). Pixel-sampled target: #F5C242 (L61 S90),
|
||||
// which the previous 1.45 floor crushed to #867000 (L26) — the reported mud.
|
||||
const DISPLAY_MIN_CONTRAST = 1.45
|
||||
const SEMANTIC_MIN_CONTRAST = 2.2
|
||||
const LIGHT_DISPLAY_MIN_CONTRAST = 1.18
|
||||
const LIGHT_SEMANTIC_MIN_CONTRAST = 1.6
|
||||
|
||||
const DISPLAY_FOREGROUNDS: readonly (keyof ThemeColors)[] = [
|
||||
'primary',
|
||||
|
|
@ -444,13 +463,15 @@ const DARK_BG_MAX_LUMINANCE = 0.35
|
|||
|
||||
function adaptColorsToBackground(colors: ThemeColors, isLight: boolean, base: ThemeColors, bg: string): ThemeColors {
|
||||
const out = { ...colors }
|
||||
const displayFloor = isLight ? LIGHT_DISPLAY_MIN_CONTRAST : DISPLAY_MIN_CONTRAST
|
||||
const semanticFloor = isLight ? LIGHT_SEMANTIC_MIN_CONTRAST : SEMANTIC_MIN_CONTRAST
|
||||
|
||||
for (const key of DISPLAY_FOREGROUNDS) {
|
||||
out[key] = liftForContrast(out[key], bg, DISPLAY_MIN_CONTRAST)
|
||||
out[key] = liftForContrast(out[key], bg, displayFloor)
|
||||
}
|
||||
|
||||
for (const key of SEMANTIC_FOREGROUNDS) {
|
||||
out[key] = liftForContrast(out[key], bg, SEMANTIC_MIN_CONTRAST)
|
||||
out[key] = liftForContrast(out[key], bg, semanticFloor)
|
||||
}
|
||||
|
||||
for (const key of ADAPTIVE_BACKGROUNDS) {
|
||||
|
|
@ -521,11 +542,16 @@ export interface ThemeTones {
|
|||
* dark muted #CC9B1F ≈ desaturate(mix(accent, bg, .19), .16) (err 3)
|
||||
* dark label #DAA520 ≈ desaturate(mix(accent, bg, .13), .16) (err 3)
|
||||
* dark status #C0C0C0 = grayOf(mix(text, bg, .24)) (err 0)
|
||||
* light muted/label #7A5A0F ≈ mix(primary, text, .27) (err 5)
|
||||
* light status #333333 = grayOf(mix(text, bg, .01)) (err 1)
|
||||
* light muted #946C08 ≈ desaturate(accent, .05) (err 2)
|
||||
* light label #8E6B13 ≈ desaturate(mix(accent, text, .03), .15) (err 2)
|
||||
* light status #6F6F6F = grayOf(mix(text, bg, .30)) (err 1)
|
||||
* light surface #F5F5F5 ≈ bg + softened accent (err 5)
|
||||
* light chip #E0D1BF = mix(surface, accent, .25) (err 3)
|
||||
* light selection #D4E4F7 ≈ mix(bg, shellDollar, .17) (err 3)
|
||||
* light chip #E0D1BF = mix(surface, accent, .25) (err 8)
|
||||
* light selection #D4E4F7 ≈ mix(bg, shellDollar, .20) (err 7)
|
||||
*
|
||||
* The light targets are the LIFT CANON: liftForContrast(dark literal,
|
||||
* white, 4.5) — what xterm's minimumContrastRatio showed on light hosts
|
||||
* for years — not hand-picked browns (those read as desaturated mud).
|
||||
*
|
||||
* The classic dark navy fills (#1a1a2e/#333355/#3a3a55) are IRREDUCIBLE from
|
||||
* gold seeds — the search bottoms out at gray, err 10–17 — so they remain
|
||||
|
|
@ -538,21 +564,24 @@ export function deriveTones(seeds: {
|
|||
shellDollar?: string
|
||||
text: string
|
||||
}): ThemeTones {
|
||||
const { accent, bg, primary, text } = seeds
|
||||
const { accent, bg, text } = seeds
|
||||
const isLight = (relativeLuminance(bg) ?? 0) > 0.5
|
||||
const inkBlend = mix(primary, text, 0.27)
|
||||
// Fill tint keeps most of the accent's chroma — a heavier desaturate here
|
||||
// read as washed-out ("a little too desat") next to authored fills.
|
||||
const surface = mix(bg, desaturate(accent, 0.15), isLight ? 0.045 : 0.09)
|
||||
|
||||
return {
|
||||
muted: isLight ? inkBlend : desaturate(mix(accent, bg, 0.19), 0.16),
|
||||
label: isLight ? inkBlend : desaturate(mix(accent, bg, 0.13), 0.16),
|
||||
statusFg: grayOf(mix(text, bg, isLight ? 0.01 : 0.24)),
|
||||
// Light knobs are fitted to the lift canon (xterm minimumContrastRatio
|
||||
// 4.5 of the classic dark golds against white — see LIGHT_SEEDS), not
|
||||
// to ink blends: muted #946C08 ≈ desat(accent .05), label #8E6B13 ≈
|
||||
// desat(mix(accent, text, .03), .15), statusFg #6F6F6F ≈ gray 30% lift.
|
||||
muted: isLight ? desaturate(accent, 0.05) : desaturate(mix(accent, bg, 0.19), 0.16),
|
||||
label: isLight ? desaturate(mix(accent, text, 0.03), 0.15) : desaturate(mix(accent, bg, 0.13), 0.16),
|
||||
statusFg: grayOf(mix(text, bg, isLight ? 0.3 : 0.24)),
|
||||
surface,
|
||||
activeRow: mix(surface, accent, 0.25),
|
||||
selection:
|
||||
isLight && seeds.shellDollar ? mix(bg, seeds.shellDollar, 0.17) : mix(surface, accent, 0.28),
|
||||
isLight && seeds.shellDollar ? mix(bg, seeds.shellDollar, 0.2) : mix(surface, accent, 0.28),
|
||||
border: mix(accent, bg, 0.25)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue