Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/DECISION-LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1446,3 +1446,45 @@ hairlines. The exemption says an inactive control need not meet AA. It
does not say it may be unreadable.
REVISIT WHEN: A theme is added. Both bounds are relative to that
theme's own live key, so the numbers do not transfer.

DECISION: D-088 Hover and press are told by light, not by an outline
STATUS: Confirmed
CHOICE: The key template gains a housing lamp behind the cap, seen
through the seam around it. Approaching a key brings that lamp up to a
low glow; pressing it takes the lamp up with the press, continuously,
rather than switching between two states. The hover outline that used
to appear on the face, and then persist through the press, is gone.
Owner report 2026-08-23: an outline popping up on hover and staying
there is a UI convention wearing a console's clothes, where a real key
in a lit housing shows more of that light as it moves.
The asymmetry needed no code. The soft-body homography already shrinks
and tilts a pressed face, so the cap pulls away from the housing
further on one side, and more of the lamp shows exactly there. The
physics had it; the lamp only had to be put behind it.
COLOUR: warm amber-gold, `KeySeamColor`, #F0A24A on the dark bench and
#C9762A on the light one. Warm on purpose: the console is cool
green-grey with a teal accent, so a warm light reads as illumination
from inside the box rather than as another status colour. A test pins
both properties - that it is warm, and that it is far enough from the
accent hue to read as a different fixture.
RENDERED FIRST, TWICE. The first attempt sat the lamp 1.5 DIP proud of
the face so a lit ring always existed. It rendered as an amber
OUTLINE, which is the exact thing being removed. The fix was to drop
the proud ring entirely and let the bloom carry it: light at a seam is
diffuse, and a hard-edged stroke of any colour reads as a border.
RESTRAINT: the elegance is in the opacity ramp, not the hue. 0 at
rest, 0.34 on hover, 0.85 held. The C# ramp constants and the XAML
style setters are pinned equal by test, because if they drift the lamp
jumps the instant a key is touched and nothing else would catch it.
BECAUSE: the outline was the last piece of the key that was not
modelled on anything physical.
REVISIT WHEN: the levels are by eye. If the glow reads as brash, the
opacities are the knob, not the colour.

AMENDMENT TO D-087 (2026-08-23, same session): the standby legend was
turned down on owner report that it could be "quite a bit dimmer".
KeyStandby is now #42786F dark and #577F79 light, sitting at the floor
of its own contract - 3.2:1 to 3.8:1 across the resting and pressed
faces - and the glow effect went from 0.30 to 0.20 opacity. It cannot
go lower without moving that floor, and 3:1 is already the large-text
threshold rather than the normal-text one.
20 changes: 20 additions & 0 deletions src/CUETools.Linux.App/Controls/SoftBodyKey.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ public static class SoftBodyKey
/// The bench is lit from above, so sinking also means sliding into shadow.</summary>
private const double Drop = 0.80;

/// <summary>Housing-lamp levels, matched to the style setters on keySeam so
/// the ramp starts exactly where hover left off and hands back to the same
/// value. If these drift apart the light jumps at the start of a press.</summary>
public const double SeamHover = 0.34;
public const double SeamPressed = 0.85;

public static void Attach(Button button)
{
if (!Enabled) return;
Expand All @@ -49,6 +55,20 @@ void Apply()
if (button.GetVisualDescendant("keyRecess") is Border recess)
recess.Opacity = amount <= 0.001 ? 0 : 1;

// The housing lamp comes up with the press, the way a console key
// brightens as it goes down, rather than switching between two
// style states. Handing it back matters: a locally set value
// outranks a style setter, so leaving one here would kill the
// hover glow permanently the first time the key was pressed. Same
// trap as D-084 (2), one property along.
if (button.GetVisualDescendant("keySeam") is Border seam)
{
if (amount <= 0.001)
seam.ClearValue(Visual.OpacityProperty);
else
seam.Opacity = SeamHover + amount * (SeamPressed - SeamHover);
}

if (amount <= 0.001)
{
face.RenderTransform = null;
Expand Down
54 changes: 49 additions & 5 deletions src/CUETools.Linux.App/Theme/AnalogControls.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,33 @@
<Border x:Name="keyRecess" Opacity="0" IsHitTestVisible="False"
CornerRadius="{TemplateBinding CornerRadius}"
Background="{DynamicResource ButtonEdge}" />
<!-- The housing lamp, seen through the seam around the cap. A key is
not welded to the console: there is always a gap at its skirt,
and a lamp inside the box shows through it. This layer sits a
hair PROUD of the face so that gap exists to be lit, and behind
it so the cap occludes it.
It needs no per-edge logic to lean the right way. The soft-body
homography shrinks and tilts a pressed face, so the cap pulls
away from the housing further on the side that is rising, and
more of this shows exactly there. The physics already had the
asymmetry; this only had to be put behind it.
The zero-offset shadow is the spill onto the console around the
key, the same recipe as the lit rail glyphs. Everything above it
must stay unclipped or the bloom is cut square at the bounds. -->
<Border x:Name="keySeam" Opacity="0" IsHitTestVisible="False"
ClipToBounds="False"
CornerRadius="{TemplateBinding CornerRadius}">
<Border.Background>
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Color="{DynamicResource KeySeamColor}" Offset="0" />
<GradientStop Color="{DynamicResource KeySeamColor}" Offset="1" />
</LinearGradientBrush>
</Border.Background>
<Border.Effect>
<DropShadowEffect OffsetX="0" OffsetY="1" BlurRadius="19" Opacity="0.9"
Color="{DynamicResource KeySeamColor}" />
</Border.Effect>
</Border>
<Border x:Name="keyFace"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
Expand Down Expand Up @@ -121,8 +148,25 @@
</Transitions>
</Setter>
</Style>
<Style Selector="Button:pointerover /template/ Border#keyFace">
<Setter Property="BorderBrush" Value="{DynamicResource ControlBorder}" />
<!-- Hover and press are told by LIGHT, not by an outline. An outline is a
UI convention wearing a console's clothes; a real key sitting in a lit
housing shows more of that light as it moves. Approaching the key
brings the lamp up to a low glow, pressing it takes the lamp up. -->
<Style Selector="Button /template/ Border#keySeam">
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Button:pointerover /template/ Border#keySeam">
<Setter Property="Opacity" Value="0.34" />
</Style>
<Style Selector="Button:pressed /template/ Border#keySeam">
<Setter Property="Opacity" Value="0.85" />
</Style>
<Style Selector="Button:disabled /template/ Border#keySeam">
<Setter Property="Opacity" Value="0" />
</Style>
<Style Selector="Button:pressed /template/ Border#keyFace">
<Setter Property="RenderTransform" Value="translateY(1.2px)" />
Expand All @@ -144,7 +188,7 @@
<Style Selector="Button:disabled /template/ ContentPresenter#keyContent">
<Setter Property="Opacity" Value="1" />
<Setter Property="Effect">
<DropShadowEffect OffsetX="0" OffsetY="0" BlurRadius="5" Opacity="0.30"
<DropShadowEffect OffsetX="0" OffsetY="0" BlurRadius="5" Opacity="0.20"
Color="{DynamicResource KeyStandbyGlow}" />
</Setter>
</Style>
Expand Down Expand Up @@ -374,7 +418,7 @@
</Style>
<Style Selector="ListBox.bank:disabled ListBoxItem /template/ ContentPresenter#bankKeyContent">
<Setter Property="Effect">
<DropShadowEffect OffsetX="0" OffsetY="0" BlurRadius="5" Opacity="0.30"
<DropShadowEffect OffsetX="0" OffsetY="0" BlurRadius="5" Opacity="0.20"
Color="{DynamicResource KeyStandbyGlow}" />
</Setter>
</Style>
Expand Down Expand Up @@ -493,7 +537,7 @@
</Style>
<Style Selector="ComboBox.window:disabled /template/ ContentPresenter#winValue">
<Setter Property="Effect">
<DropShadowEffect OffsetX="0" OffsetY="0" BlurRadius="5" Opacity="0.30"
<DropShadowEffect OffsetX="0" OffsetY="0" BlurRadius="5" Opacity="0.20"
Color="{DynamicResource KeyStandbyGlow}" />
</Setter>
</Style>
Expand Down
29 changes: 21 additions & 8 deletions src/CUETools.Linux.App/Theme/Palette.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,19 @@
<SolidColorBrush x:Key="StatusDanger" Color="#E06C75" />
<Color x:Key="StatusAccentColor">#34CFC0</Color>
<!-- a dead key's legend, lit at standby current: the bulb behind the
translucent letters is not off, it is turned right down. 4.0:1 to
4.7:1 across the resting and pressed faces, against 14:1 for a live
key and the 1.2:1 the old structural Line gave, which could not be
read at all. -->
<SolidColorBrush x:Key="KeyStandby" Color="#4E8880" />
translucent letters is not off, it is turned right down. Turned
down again on 08-23 owner note; this now sits at the floor of its
own contract, 3.2:1 to 3.8:1 across the resting and pressed faces,
against 14:1 for a live key and the 1.2:1 the old structural Line
gave, which could not be read at all. It cannot go lower without
moving that floor, and 3:1 is already the large-text threshold. -->
<SolidColorBrush x:Key="KeyStandby" Color="#42786F" />
<!-- the housing lamp under a key, seen through the seam around its cap.
Warm on purpose: the console is cool green-grey with a teal accent,
so a warm light reads as illumination from inside the box rather
than as another status colour. Amber-gold rather than orange, and
the restraint lives in the opacity ramp, not the hue. -->
<Color x:Key="KeySeamColor">#F0A24A</Color>
<Color x:Key="KeyStandbyGlow">#34CFC0</Color>
<Color x:Key="KeyShoulderMid">#2E000000</Color>
<Color x:Key="KeyShoulderEdge">#5A000000</Color>
Expand Down Expand Up @@ -89,12 +97,17 @@
<!-- the same standby legend on the pale bench. A backlight barely shows
against a light console in a lit room, which is true of the real
thing too, so here the reading is carried by the ink and the glow
is only a tint. 3.6:1 to 4.9:1 across the resting and pressed faces,
is only a tint. 3.2:1 to 4.3:1 across the resting and pressed faces,
against 16:1 live and the 1.5:1 the old structural Line gave. The
pressed face is the binding one here: it is the darkest thing a
standby legend can sit on, which happens when a key is disabled
mid-press and takes the dead release. -->
<SolidColorBrush x:Key="KeyStandby" Color="#4A7770" />
mid-press and takes the dead release. Turned down on the 08-23
owner note to the floor of its own contract. -->
<SolidColorBrush x:Key="KeyStandby" Color="#577F79" />
<!-- the same housing lamp on the pale bench. A glow barely registers
against a light console in a lit room, which is true of the real
thing, so this is deeper and more saturated to read at all. -->
<Color x:Key="KeySeamColor">#C9762A</Color>
<Color x:Key="KeyStandbyGlow">#087067</Color>
<!-- rubber is matte: a strong crown here reads as polished chrome, not
a soft cap. Kept to a gentle lift on the light bench. -->
Expand Down
87 changes: 87 additions & 0 deletions tests/CUETools.Linux.Tests/SeamLightTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Text.RegularExpressions;
using Avalonia.Headless.XUnit;
using Avalonia.Media;
using Avalonia.Styling;
using CUETools.Linux.App.Controls;
using Xunit;

namespace CUETools.Linux.Tests;

// D-088. Hover and press are told by light from the housing rather than by an
// outline. Three things about that can break quietly, so all three are pinned.
public class SeamLightTests
{
private static string Theme() => File.ReadAllText(
Path.Combine(FindRepoRoot(), "src", "CUETools.Linux.App", "Theme", "AnalogControls.axaml"));

private static string FindRepoRoot()
{
string? dir = AppContext.BaseDirectory;
while (dir != null && !File.Exists(Path.Combine(dir, "eng", "build.sh")))
dir = Path.GetDirectoryName(dir);
Assert.NotNull(dir);
return dir!;
}

private static double SeamOpacityFor(string pseudoClass)
{
// the style block for keySeam under this pseudo-class, and the opacity it sets
var m = Regex.Match(
Theme(),
@"Selector=""Button:" + Regex.Escape(pseudoClass) +
@"\s*/template/\s*Border#keySeam""\s*>\s*<Setter\s+Property=""Opacity""\s+Value=""([0-9.]+)""",
RegexOptions.Singleline);
Assert.True(m.Success, $"no keySeam opacity setter found for :{pseudoClass}");
return double.Parse(m.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture);
}

[Fact]
public void ThePressRampStartsExactlyWhereHoverLeftOff()
{
// the styles own the resting and hover levels; the soft-body ramp drives
// the same property while a press is held and hands it back on release.
// If the two disagree the lamp jumps the instant the key is touched, and
// nothing else in the suite would notice.
Assert.Equal(SoftBodyKey.SeamHover, SeamOpacityFor("pointerover"), 3);
Assert.Equal(SoftBodyKey.SeamPressed, SeamOpacityFor("pressed"), 3);
Assert.True(SoftBodyKey.SeamPressed > SoftBodyKey.SeamHover,
"pressing a key has to brighten its lamp, not dim it");
}

[Fact]
public void HoverNoLongerDrawsAnOutlineOnTheKeyFace()
{
// the owner's report (2026-08-23): the hover outline popped up and then
// persisted through the press. An outline is a UI convention wearing a
// console's clothes; this guards the replacement from being undone.
Assert.DoesNotMatch(
new Regex(@"Selector=""Button:pointerover\s*/template/\s*Border#keyFace"""),
Theme());
}

[AvaloniaFact]
public void TheHousingLampIsWarmAndNotMistakableForTheAccent()
{
// the console is cool green-grey with a teal accent. The lamp has to read
// as light from inside the box, which means warm - and a lamp that drifted
// toward the accent hue would read as another status colour instead.
foreach (ThemeVariant variant in new[] { ThemeVariant.Dark, ThemeVariant.Light })
{
Assert.True(Avalonia.Application.Current!.TryGetResource(
"KeySeamColor", variant, out object? value), "KeySeamColor missing");
var seam = Assert.IsType<Color>(value);

Assert.True(seam.R > seam.B + 60,
$"{variant} KeySeamColor #{seam} is not warm: red must lead blue clearly");
Assert.True(seam.R >= seam.G && seam.G > seam.B,
$"{variant} KeySeamColor #{seam} is not on the amber side of warm");

Assert.True(Avalonia.Application.Current.TryGetResource(
"StatusAccentColor", variant, out object? accentValue), "StatusAccentColor missing");
var accent = Assert.IsType<Color>(accentValue);
int distance = Math.Abs(seam.R - accent.R) + Math.Abs(seam.G - accent.G) + Math.Abs(seam.B - accent.B);
Assert.True(distance > 150,
$"{variant} the housing lamp #{seam} is too close to the accent #{accent} to read as a different fixture");
}
}
}
Loading