tide: derive the light segment background from the terminal's own

A fixed E4E4E4 segment was 1.04:1 against Kaolin Breeze's EBE8E4 -- an
invisible pill -- and a neutral grey reads as a foreign object on a warm
or cool background anyway. Read the background kitty is showing and place
the segment a fixed contrast step below it, so it keeps the theme's tint.
Default step is 1.12:1; __tide_terminal_segment_contrast overrides it.

Moving the segment invalidates the foregrounds, which are authored at
exactly 4.5:1 against E4E4E4 with no headroom, so re-aim them at the
derived segment by the single ratio the palette's shared luminance ceiling
moved by. That scaling runs in linear light, where luminance is a weighted
sum of the channels and so scales exactly; scaling the encoded channels
drifts further the darker a color gets, which is where the floor binds.

Also fix the baseline capture: the overrides are exported, so a Fish
started from a Fish that had already applied a palette was capturing that
palette as its dark baseline and could never restore dark. Drop inherited
globals first so the baseline comes from the universal scope Tide writes.

Dark mode is unchanged.
This commit is contained in:
Anton Volnuhin 2026-08-06 15:34:51 +03:00
parent 8c9e5e2c99
commit e5207ac154
11 changed files with 438 additions and 31 deletions

View File

@ -1,8 +1,61 @@
function __tide_apply_terminal_appearance --argument-names appearance
function __tide_apply_terminal_appearance --argument-names appearance background
contains -- $appearance dark light
or return 2
# The palette Tide ships is authored for a dark terminal, where every
# segment shares this background.
set -l dark_segment_background 303030
# The background __tide_terminal_light_color's foregrounds are tuned against,
# and the ratio they are tuned to.
set -l reference_background E4E4E4
set -l reference_contrast 4.5
# How far the segment background sits from the terminal background. Set
# $__tide_terminal_segment_contrast to taste: 1.04:1 is what a fixed E4E4E4
# gave on a warm background and is invisible, 1.4:1 reads heavy.
set -l segment_contrast 1.12
if string match -qr '^[0-9]+(\.[0-9]+)?$' -- "$__tide_terminal_segment_contrast"
and test (math -s0 "round($__tide_terminal_segment_contrast * 100)") -gt 100
set segment_contrast $__tide_terminal_segment_contrast
end
# What the foregrounds are re-aimed at. The extra margin over
# $reference_contrast absorbs the rounding back to 8 bits per channel.
set -l foreground_contrast 4.55
# Used when the terminal will not say what its background is. White is the
# common default for light terminals.
set -l fallback_background FFFFFF
set -l segment_background
set -l foreground_factor 1
if test "$appearance" = light
test -z "$background"
and set background (__tide_terminal_background_color light)
string match -qr '^[0-9A-Fa-f]{6}$' -- "$background"
or set background $fallback_background
set background (string upper -- $background)
# Place the segment a fixed contrast step below the terminal background,
# so it picks up that background's tint instead of imposing a grey.
set -l background_luminance (math "max(0.000001, "(__tide_relative_luminance $background)")")
set -l segment_luminance (__tide_contrast_ceiling $background_luminance $segment_contrast)
set segment_background (__tide_scale_luminance $background \
(math "$segment_luminance / $background_luminance"))
# Every light foreground is tuned to sit at $reference_contrast against
# the reference background, so the palette shares one luminance ceiling.
# A darker segment lowers that ceiling, and moving every foreground by
# the same luminance factor walks them all down to it at once.
set -l reference_ceiling (__tide_contrast_ceiling \
(__tide_relative_luminance $reference_background) $reference_contrast)
set -l segment_ceiling (__tide_contrast_ceiling \
(__tide_relative_luminance $segment_background) $foreground_contrast)
set foreground_factor (math "min(1, $segment_ceiling / $reference_ceiling)")
else
set background ''
end
if test "$__tide_terminal_appearance" = "$appearance"
and test "$__tide_terminal_background" = "$background"
return 1
end
@ -14,6 +67,14 @@ function __tide_apply_terminal_appearance --argument-names appearance
string match -q 'tide_*color*' -- $name
or continue
# These overrides are exported, so a Fish started from a Fish that
# already applied a palette inherits them and would otherwise
# capture that palette as its baseline. Dropping the inherited
# global makes the name resolve to its universal value again, which
# is the configured dark palette. Every name here comes from the
# universal scope, so there is always something left to resolve to.
set --erase --global $name
set -l current_value $$name
test (count $current_value) -eq 1
or continue
@ -31,11 +92,25 @@ function __tide_apply_terminal_appearance --argument-names appearance
for index in (seq (count $__tide_terminal_color_names))
set -l name $__tide_terminal_color_names[$index]
set -l color $__tide_terminal_dark_colors[$index]
if test "$appearance" = light
set color (__tide_terminal_light_color $color)
if string match -q '*bg_color*' -- $name
and test (string upper -- $color) = $dark_segment_background
set color $segment_background
else
set -l light_color (__tide_terminal_light_color $color)
set -l dimmed (__tide_scale_luminance $light_color $foreground_factor)
if test -n "$dimmed"
set color $dimmed
else
set color $light_color
end
end
end
set --global --export $name $color
end
set --global __tide_terminal_appearance $appearance
set --global __tide_terminal_background $background
end

View File

@ -0,0 +1,12 @@
function __tide_contrast_ceiling --argument-names luminance ratio \
--description 'Print the highest luminance that still meets a contrast ratio against a background'
string match -qr '^[0-9]+(\.[0-9]+)?$' -- $luminance
or return 1
string match -qr '^[0-9]+(\.[0-9]+)?$' -- $ratio
or return 1
# WCAG contrast is (lighter + 0.05) / (darker + 0.05), so the darkest
# partner a background can carry at a given ratio falls straight out of it.
math "max(0, ($luminance + 0.05) / $ratio - 0.05)"
end

View File

@ -0,0 +1,15 @@
function __tide_relative_luminance --argument-names hex \
--description 'Print the WCAG relative luminance of a six-digit hex color'
string match -qr '^[0-9A-Fa-f]{6}$' -- $hex
or return 1
set -l r (string sub --start 1 --length 2 -- $hex)
set -l g (string sub --start 3 --length 2 -- $hex)
set -l b (string sub --start 5 --length 2 -- $hex)
# sRGB relative luminance. WCAG's piecewise-linear segment for channels
# below 0.04045 is left out: it only applies under 10/255 and shifts the
# result by less than 0.001, which never changes a palette decision here.
math "0.2126 * ((0x$r / 255 + 0.055) / 1.055) ^ 2.4 + 0.7152 * ((0x$g / 255 + 0.055) / 1.055) ^ 2.4 + 0.0722 * ((0x$b / 255 + 0.055) / 1.055) ^ 2.4"
end

View File

@ -0,0 +1,26 @@
function __tide_scale_luminance --argument-names hex factor \
--description 'Multiply a color\'s luminance by a factor, keeping its hue and saturation'
string match -qr '^[0-9A-Fa-f]{6}$' -- $hex
or return 1
string match -qr '^[0-9]+(\.[0-9]+)?$' -- $factor
or return 1
set -l channels
for start in 1 3 5
set -l encoded (string sub --start $start --length 2 -- $hex)
# Decode to linear light, scale, re-encode. Scaling there multiplies
# relative luminance by exactly this factor, because luminance is a
# weighted sum of the linear channels. Scaling the encoded channels
# instead would not: sRGB's +0.055 offset makes the error grow as the
# color gets darker, which is where the contrast floor actually bites.
set -l linear "($factor * ((0x$encoded / 255 + 0.055) / 1.055) ^ 2.4)"
set --append channels "round(255 * max(0, min(1, (1.055 * $linear ^ (1 / 2.4)) - 0.055)))"
end
# One math call per color rather than three: the bytes are packed into a
# single number and printed back out as hex. -s0 truncates, so each
# channel does its own rounding above.
printf %06X (math -s0 "$channels[1] * 65536 + $channels[2] * 256 + $channels[3]")
end

View File

@ -0,0 +1,46 @@
function __tide_terminal_background_color --argument-names appearance \
--description 'Print the background color kitty is showing for an appearance'
contains -- $appearance dark light
or return 1
functions -q __kitty_config_dir
or return 1
set -l config_dir (__kitty_config_dir)
# kitty gives the *-theme.auto.conf files priority over includes and even
# over --override, so whatever is in them is what the window is actually
# showing -- whether it got there from `kitten themes` or from
# apply-theme-mode.sh pinning a mode.
for file in $config_dir/$appearance-theme.auto.conf $config_dir/no-preference-theme.auto.conf
test -r $file
or continue
set -l hex (string match -gr '^\s*background\s+#?([0-9A-Fa-f]{6})\s*$' <$file | tail -n 1)
test -n "$hex"
or continue
set hex (string upper -- $hex)
set -l luminance (__tide_relative_luminance $hex)
test -n "$luminance"
or continue
# A background that disagrees with the appearance Fish reported means
# we read the wrong file. A pill derived from the wrong background is
# worse than the static fallback, so skip it.
set -l scaled (math -s0 "round($luminance * 1000)")
if test "$appearance" = light
test $scaled -gt 500
or continue
else
test $scaled -lt 500
or continue
end
echo $hex
return 0
end
return 1
end

View File

@ -0,0 +1,102 @@
# Tide Terminal Appearance Design
## Goal
Keep the existing Tide prompt unchanged except for its colors. The prompt must
follow the active Kitty or Ghostty terminal appearance, update while the shell
is open, and continue to work inside tmux.
## Non-goals
- Do not modify Tide's installed functions.
- Do not change prompt items, icons, separators, shape, spacing, or layout.
- Do not infer appearance from macOS.
- Do not share one terminal's appearance with unrelated Fish sessions.
## Design
Add one Fish integration file under `conf.d/`.
Fish 4.8 already negotiates terminal color-scheme notifications with Kitty,
Ghostty, and tmux, consumes the terminal replies, queries the actual background,
and publishes the result as the read-only `fish_terminal_color_theme` variable.
The integration subscribes to changes of that variable instead of parsing raw
terminal input itself.
The handler changes only Tide color variables:
- Dark mode keeps the currently configured values exactly.
- Light mode substitutes darker, contrast-safe equivalents for foreground
colors while preserving each color's semantic role, and replaces the shared
segment background `303030` with a shade of the terminal's own background.
The light segment background is derived rather than fixed. A single neutral
value cannot sit at a usable distance from every light theme -- `E4E4E4` is
1.03:1 against Kaolin Breeze's `EBE8E4`, which is invisible -- and a neutral
grey reads as a foreign object on a warm or cool background. So the handler
reads the background kitty is showing and places the segment a fixed contrast
step below it, which keeps the segment in the theme's own tint.
That step is a judgement call rather than a threshold, and it is not only about
the segment: the foregrounds are re-aimed at whatever the segment becomes, so a
heavier step darkens the text along with it and the segment starts reading as a
blob. `1.12:1` is the default. `__tide_terminal_segment_contrast` overrides it,
and values that are not a ratio above 1:1 are ignored, since they would put the
segment level with or above its own background.
kitty's background comes from the `*-theme.auto.conf` files. kitty gives those
priority over includes and over `--override`, so they are what a window is
actually showing however the theme got there. A background whose luminance
contradicts the appearance Fish reported means the wrong file was read, and is
discarded in favour of the fallback. Terminals that are not kitty get the same
treatment against an assumed white background.
Moving the segment invalidates the foreground palette, because every light
foreground is authored to sit at exactly 4.5:1 against the reference background
`E4E4E4`, leaving no headroom. Since the whole palette shares one luminance
ceiling, a darker segment lowers that ceiling by a single ratio, and every
foreground is multiplied by it. That scaling happens in linear light, where
relative luminance is a weighted sum of the channels and therefore scales
exactly; scaling the encoded channels instead would drift by more as colors get
darker, which is where the contrast floor actually binds.
The overrides are exported, so a Fish started from a Fish that already applied a
palette inherits them. The capture step drops those inherited globals first, so
each process reads its baseline from the universal scope Tide configures rather
than from its parent's active palette.
Overrides use exported global scope. This shadows Tide's universal defaults for
the current Fish process and passes the selected palette to Tide's background
rendering subprocess without affecting Fish sessions in other terminal windows.
After a scheme event, the handler refreshes Tide's cached colors, reloads the
generated prompt functions, and requests a command-line repaint. Reapplying the
current scheme is a no-op.
## Failure behavior
On terminals that do not report a usable appearance,
`fish_terminal_color_theme` remains `unknown` and Tide keeps its existing dark
configuration. Fish owns the terminal protocol bytes, so they never become user
key bindings or command-line input.
## Verification
Automated Fish tests verify:
- only Tide color variables change;
- non-color Tide configuration remains identical;
- dark values equal the current configuration;
- light foregrounds meet the chosen contrast threshold against the derived
segment background, on both a theme background and the fallback;
- scaling a color's luminance moves it by the requested factor, whatever its hue;
- the background probe reads the active theme and rejects one that contradicts
the reported appearance;
- exported global overrides reach a child Fish process;
- repeated events are idempotent, and a changed background is not a repeat;
- the integration subscribes to Fish's native terminal-theme variable;
- Fish parses every changed file.
Pseudo-terminal acceptance tests emulate light/dark terminal responses and
cover both direct Fish and Fish through tmux. Kitty and Ghostty share the
protocol that Fish consumes.

View File

@ -22,7 +22,13 @@ const luminance = (hex) => {
);
};
const backgroundLuminance = luminance('E4E4E4');
const background = (process.argv[2] ?? 'E4E4E4').toUpperCase();
if (!/^[0-9A-F]{6}$/.test(background)) {
console.error(`background ${background}: expected a six-digit color`);
process.exit(1);
}
const backgroundLuminance = luminance(background);
const failures = [];
for (const line of input.split('\n')) {
@ -40,7 +46,9 @@ for (const line of input.split('\n')) {
);
if (ratio < 4.5) {
failures.push(`${dark} -> ${light}: contrast ${ratio.toFixed(2)} is below 4.5`);
failures.push(
`${dark} -> ${light}: contrast ${ratio.toFixed(2)} against ${background} is below 4.5`,
);
}
}

View File

@ -0,0 +1,3 @@
# vim:ft=kitty
background #000000
foreground #FFFFFF

View File

@ -0,0 +1,3 @@
# vim:ft=kitty
background #FFFFFF
foreground #000000

View File

@ -33,17 +33,34 @@ end
set -l light_mapper $project_root/functions/__tide_terminal_light_color.fish
set -l appearance_applicator $project_root/functions/__tide_apply_terminal_appearance.fish
set -l luminance_helper $project_root/functions/__tide_relative_luminance.fish
set -l scale_helper $project_root/functions/__tide_scale_luminance.fish
set -l ceiling_helper $project_root/functions/__tide_contrast_ceiling.fish
set -l background_probe $project_root/functions/__tide_terminal_background_color.fish
test -f $light_mapper
or __test_fail "missing light palette mapper"
test -f $appearance_applicator
or __test_fail "missing terminal appearance applicator"
for file in $light_mapper $appearance_applicator $luminance_helper $scale_helper \
$ceiling_helper $background_probe
test -f $file
or __test_fail "missing "(path basename $file)
end
source $light_mapper
source $luminance_helper
source $scale_helper
source $ceiling_helper
source $background_probe
source $appearance_applicator
# The applicator exports its overrides, so a shell that already applied a
# palette passes them to this one through the environment. Dropping the
# inherited globals makes every tide_* name resolve to its universal value
# again, which is the configured dark palette this suite is written against.
for name in (set --universal --names | string match -e -r '^tide_')
set --erase --global $name
end
__test_assert_equal E4E4E4 (__tide_terminal_light_color 303030) \
"dark segment background maps to the light segment background"
"dark segment background maps to the reference light segment background"
__test_assert_equal 006C9E (__tide_terminal_light_color 00AFFF) \
"bright blue maps to its contrast-safe light equivalent"
__test_assert_equal CF0000 (__tide_terminal_light_color FF0000) \
@ -51,13 +68,61 @@ __test_assert_equal CF0000 (__tide_terminal_light_color FF0000) \
__test_assert_equal glyph (__tide_terminal_light_color glyph) \
"non-color values are preserved"
set -l reference_luminance (__tide_relative_luminance E4E4E4)
test (math -s0 "round($reference_luminance * 1000)") -eq 776
or __test_fail "relative luminance of E4E4E4: expected 0.776, got $reference_luminance"
# Halving a color's luminance has to actually halve it, whatever its hue.
# Only the rounding back to 8 bits per channel is allowed to show up.
for color in EBE8E4 006C9E 7B6400 4C6C6C
set -l halved (__tide_scale_luminance $color 0.5)
set -l ratio (math (__tide_relative_luminance $halved)" / "(__tide_relative_luminance $color))
set -l scaled (math -s0 "round($ratio * 1000)")
test $scaled -ge 490 -a $scaled -le 510
or __test_fail "scaling $color by 0.5 moved luminance by $ratio, not 0.5"
end
__test_assert_equal FFFFFF (__tide_scale_luminance 808080 100) \
"scaling clamps channels at the top of the range"
__test_assert_equal 000000 (__tide_scale_luminance 808080 0) \
"scaling clamps channels at the bottom of the range"
__tide_scale_luminance nothex 0.5
__test_assert_equal 1 $status "scaling rejects values that are not colors"
__test_assert_equal 0.116667 (__tide_contrast_ceiling 0.7 4.5) \
"the contrast ceiling is the darkest partner a background can carry"
__test_assert_equal 0 (__tide_contrast_ceiling 0.1 4.5) \
"an unreachable contrast ceiling clamps at zero"
set -l fake_config (mktemp -d)
printf 'background #EBE8E4\n' >$fake_config/light-theme.auto.conf
# A light background under a dark appearance means the wrong file was read, so
# the probe has to skip it and keep looking.
printf 'background #FFFFFF\n' >$fake_config/dark-theme.auto.conf
printf 'background #101010\n' >$fake_config/no-preference-theme.auto.conf
__test_assert_equal EBE8E4 \
(KITTY_CONFIG_DIRECTORY=$fake_config __tide_terminal_background_color light) \
"the probe reads the background kitty is showing"
__test_assert_equal 101010 \
(KITTY_CONFIG_DIRECTORY=$fake_config __tide_terminal_background_color dark) \
"the probe rejects a background that contradicts the appearance"
set -l empty_config (mktemp -d)
KITTY_CONFIG_DIRECTORY=$empty_config __tide_terminal_background_color light
__test_assert_equal 1 $status "the probe reports failure when no theme is readable"
rm -rf $fake_config $empty_config
set -l empty_palette_result (
fish --no-config -c '
source $argv[1]
source $argv[2]
__tide_apply_terminal_appearance light
for file in $argv[2..]
source $file
end
__tide_apply_terminal_appearance light $argv[1]
printf "status=%s" $status
' $light_mapper $appearance_applicator 2>&1 |
' EBE8E4 $light_mapper $luminance_helper $scale_helper $ceiling_helper \
$background_probe $appearance_applicator 2>&1 |
string collect
)
__test_assert_equal status=2 $empty_palette_result \
@ -77,19 +142,19 @@ or __test_fail "the test fixture has no Tide colors"
set -l before_non_colors (string join \x1e (__test_snapshot no))
__tide_apply_terminal_appearance light
__tide_apply_terminal_appearance light EBE8E4
__test_assert_equal 0 $status "light appearance applies successfully"
__test_assert_equal E4E4E4 $tide_pwd_bg_color \
"light appearance replaces the existing prompt segment background"
__test_assert_equal 006C9E $tide_pwd_color_anchors \
"light appearance replaces the existing prompt foreground"
__test_assert_equal DFDCD8 $tide_pwd_bg_color \
"light appearance derives the segment background from the terminal background"
__test_assert_equal 006696 $tide_pwd_color_anchors \
"light appearance shades foregrounds down to the derived segment background"
set -l pwd_scope (set --show tide_pwd_bg_color | string collect)
string match -q '*set in global scope, exported*' -- $pwd_scope
or __test_fail "Tide overrides are not global and exported"
set -l child_background (fish --no-config -c 'printf %s "$tide_pwd_bg_color"')
__test_assert_equal E4E4E4 $child_background \
__test_assert_equal DFDCD8 $child_background \
"Tide renderer subprocess inherits the light background"
set -l after_non_colors (string join \x1e (__test_snapshot no))
@ -101,7 +166,7 @@ for name in $expected_color_names
set --append first_light_state "$name="(string escape -- $$name)
end
__tide_apply_terminal_appearance light
__tide_apply_terminal_appearance light EBE8E4
__test_assert_equal 1 $status "repeated light appearance is a no-op"
set -l second_light_state
@ -112,14 +177,60 @@ __test_assert_equal (string join \x1e $first_light_state) \
(string join \x1e $second_light_state) \
"repeated light appearance preserves all color overrides"
set --global __test_color_names $expected_color_names
set --global __test_dark_colors $expected_dark_colors
set --global __test_contrast_checker $project_root/tests/assert_tide_light_contrast.mjs
function __test_assert_light_contrast --argument-names label
begin
for index in (seq (count $expected_dark_colors))
set -l dark (string upper -- $expected_dark_colors[$index])
for index in (seq (count $__test_color_names))
set -l name $__test_color_names[$index]
set -l dark (string upper -- $__test_dark_colors[$index])
test "$dark" = 303030; and continue
printf '%s %s\n' $dark (__tide_terminal_light_color $dark)
printf '%s %s\n' $dark (string upper -- $$name)
end
end | node $project_root/tests/assert_tide_light_contrast.mjs
or __test_fail "light palette foreground contrast is below 4.5:1"
end | node $__test_contrast_checker $tide_pwd_bg_color
or __test_fail "light palette foreground contrast is below 4.5:1 on $label"
end
__test_assert_light_contrast "a terminal-derived segment background"
# A different terminal background is a different palette, not a repeat.
__tide_apply_terminal_appearance light nothex
__test_assert_equal 0 $status "a changed terminal background re-applies the palette"
__test_assert_equal F2F2F2 $tide_pwd_bg_color \
"an unusable terminal background falls back to white"
__test_assert_light_contrast "the fallback segment background"
__tide_apply_terminal_appearance light FFFFFF
__test_assert_equal 1 $status \
"a background that resolves to the active one is still a no-op"
__tide_apply_terminal_appearance light EBE8E4
__test_assert_equal 0 $status "returning to the earlier background re-applies"
__test_assert_equal DFDCD8 $tide_pwd_bg_color \
"returning to the earlier background restores its segment background"
set --global __tide_terminal_segment_contrast 1.4
set --global __tide_terminal_appearance ''
__tide_apply_terminal_appearance light EBE8E4
__test_assert_equal C8C6C2 $tide_pwd_bg_color \
"a configured segment contrast overrides the default step"
__test_assert_light_contrast "a configured segment contrast"
# Anything that is not a ratio above 1:1 would make the segment vanish into the
# background or sit above it, so it is ignored rather than honoured.
for invalid in 0.8 1 nonsense ''
set --global __tide_terminal_segment_contrast $invalid
set --global __tide_terminal_appearance ''
__tide_apply_terminal_appearance light EBE8E4
__test_assert_equal DFDCD8 $tide_pwd_bg_color \
"a segment contrast of <$invalid> falls back to the default step"
end
set --erase __tide_terminal_segment_contrast
set --global __tide_terminal_appearance ''
__tide_apply_terminal_appearance light EBE8E4
__tide_apply_terminal_appearance dark
__test_assert_equal 0 $status "dark appearance applies successfully"

View File

@ -3,15 +3,21 @@
set timeout 30
log_user 0
# The segment background is derived from the theme kitty is showing, so the
# emulated terminal responses below and the theme files have to agree. These
# fixtures are the white/black the responses report.
set kitty_config [file join [file dirname [file normalize [info script]]] fixtures kitty]
set path_label "direct"
set through_tmux 0
if {[llength $argv] > 0 && [lindex $argv 0] eq "tmux"} {
set path_label "tmux"
set through_tmux 1
spawn env TERM=xterm-kitty tmux -L tide-appearance-test \
spawn env TERM=xterm-kitty KITTY_CONFIG_DIRECTORY=$kitty_config \
tmux -L tide-appearance-test \
-f "$env(HOME)/.tmux.conf" new-session fish --interactive
} else {
spawn env TERM=xterm-kitty fish --interactive
spawn env TERM=xterm-kitty KITTY_CONFIG_DIRECTORY=$kitty_config fish --interactive
}
if {$through_tmux} {
@ -60,7 +66,7 @@ if {$through_tmux} {
send -- "printf 'CHECK:%s:%s\\n' \$__tide_terminal_appearance \$tide_pwd_bg_color\r"
expect {
-re {CHECK:light:E4E4E4} {}
-re {CHECK:light:F2F2F2} {}
timeout {
puts stderr "The initial light terminal background did not apply the light Tide palette"
exit 1