fix(socials): live preview of the bar appearance + the crash on dragging a slider
Two bugs from the creator's first look at the new controls.
1. CRASH — '{DependencyProperty.UnsetValue}' is not a valid value for property 'Fill'.
The token conversion deleted `YtSliderKnobHoverBrush` while two slider triggers still
referenced it. WPF does not fail that at build time: it substitutes UnsetValue and throws at
the next layout, inside `Ellipse.OnRender` — so the first slider touched (the new opacity one)
killed the app. The brush is back, as a glossier version of the rest state.
Guard: `ThemeResourceTests` scans every app XAML and proves each {StaticResource} key is
defined (theme/App resources or the same file). Proven by removing the key again — the test
names it.
2. PREVIEW DID NOTHING — the preview bar is its own XAML rendering (not the rasterized strip the
compositor overlays), and it was hard-wired white-on-transparent, so the drawer's controls had
nothing to move. Now:
- `SocialBarTextBrush` / `SocialBarBackgroundBrush` come from the same `SocialBarStyle` the
renderer bakes, so preview and output cannot disagree.
- the preview draws the plate (same colour + opacity) and the drop shadow.
- the drawer applies changes to the live config as you edit (`ApplyAppearancePreview`), and
Cancel restores the pre-edit snapshot — so the preview tracks the controls without
persisting anything until Save.
46 tests green (theme, socials, design tokens, layout store, real-MainWindow meter test).
This commit is contained in:
@@ -351,8 +351,12 @@
|
||||
<DropShadowEffect Color="{Binding SocialBarGlowBrush.Color}"
|
||||
BlurRadius="18" ShadowDepth="0" Opacity="0.9"/>
|
||||
</Grid.Effect>
|
||||
<Border HorizontalAlignment="Center" Margin="40,8"
|
||||
CornerRadius="20" Padding="18,6"
|
||||
Background="{Binding SocialBarBackgroundBrush}"
|
||||
Visibility="{Binding Socials.BarBackgroundEnabled, Converter={StaticResource BoolToVis}}">
|
||||
<ItemsControl ItemsSource="{Binding Socials.Entries}"
|
||||
HorizontalAlignment="Center" Margin="40,8"
|
||||
HorizontalAlignment="Center"
|
||||
IsHitTestVisible="False">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
@@ -362,15 +366,45 @@
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,20,0">
|
||||
<Path Data="{Binding LogoData}" Fill="White" Width="24" Height="24"
|
||||
Stretch="Uniform" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Handle}" Foreground="White"
|
||||
FontSize="14" VerticalAlignment="Center"
|
||||
Margin="8,0,0,0"/>
|
||||
<Path Data="{Binding LogoData}" Width="24" Height="24"
|
||||
Fill="{Binding DataContext.SocialBarTextBrush, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||
Stretch="Uniform" VerticalAlignment="Center">
|
||||
<Path.Style>
|
||||
<Style TargetType="Path">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding DataContext.Socials.BarTextShadow, RelativeSource={RelativeSource AncestorType=ItemsControl}}" Value="True">
|
||||
<Setter Property="Effect">
|
||||
<Setter.Value>
|
||||
<DropShadowEffect Color="Black" BlurRadius="4" ShadowDepth="2" Direction="315" Opacity="0.85"/>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Path.Style>
|
||||
</Path>
|
||||
<TextBlock Text="{Binding Handle}" FontSize="14" VerticalAlignment="Center"
|
||||
Margin="8,0,0,0"
|
||||
Foreground="{Binding DataContext.SocialBarTextBrush, RelativeSource={RelativeSource AncestorType=ItemsControl}}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding DataContext.Socials.BarTextShadow, RelativeSource={RelativeSource AncestorType=ItemsControl}}" Value="True">
|
||||
<Setter Property="Effect">
|
||||
<Setter.Value>
|
||||
<DropShadowEffect Color="Black" BlurRadius="4" ShadowDepth="2" Direction="315" Opacity="0.85"/>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Canvas>
|
||||
</Grid>
|
||||
|
||||
@@ -359,6 +359,16 @@
|
||||
<GradientStop Color="{StaticResource Color.Tertiary}" Offset="1"/>
|
||||
</RadialGradientBrush>
|
||||
|
||||
<!-- Hover/drag knob: the same gloss with the highlight spread wider. The conversion to
|
||||
tokens deleted this key while two triggers still referenced it, and WPF turns a missing
|
||||
StaticResource into DependencyProperty.UnsetValue — which crashes Ellipse.OnRender at
|
||||
the next layout. ThemeResourceTests now catches that class of defect. -->
|
||||
<RadialGradientBrush x:Key="YtSliderKnobHoverBrush" Center="0.35,0.3" GradientOrigin="0.35,0.3">
|
||||
<GradientStop Color="#ffffff" Offset="0"/>
|
||||
<GradientStop Color="#ffffff" Offset="0.3"/>
|
||||
<GradientStop Color="{StaticResource Color.Tertiary}" Offset="1"/>
|
||||
</RadialGradientBrush>
|
||||
|
||||
<Style x:Key="YtSliderTrackButton" TargetType="RepeatButton">
|
||||
<Setter Property="Focusable" Value="False"/>
|
||||
<Setter Property="Template">
|
||||
|
||||
@@ -31,6 +31,13 @@ public partial class MainViewModel
|
||||
/// <summary>Green glow on the bar while it's on the stream.</summary>
|
||||
public SolidColorBrush SocialBarGlowBrush => BarOnBrush;
|
||||
|
||||
/// <summary>Glyph colour of the bar — the preview draws the same value the renderer paints,
|
||||
/// so the two cannot drift apart (creator request 2026-10-30).</summary>
|
||||
public SolidColorBrush SocialBarTextBrush => SocialBarStyle.From(_socials).TextBrush;
|
||||
|
||||
/// <summary>The preview's plate: same colour AND same opacity the renderer bakes in.</summary>
|
||||
public SolidColorBrush SocialBarBackgroundBrush => SocialBarStyle.From(_socials).BackgroundBrush;
|
||||
|
||||
/// <summary>The global socials config (entries + bar settings), or null.</summary>
|
||||
public SocialsConfig? Socials => _socials;
|
||||
|
||||
@@ -137,13 +144,28 @@ public partial class MainViewModel
|
||||
SignOutYouTubeAsync,
|
||||
_youtubeAuth.CurrentChannel,
|
||||
_socials);
|
||||
// Live preview: the drawer edits the REAL config, so colour/opacity/shadow land on the
|
||||
// preview immediately instead of waiting for Save. Cancel restores the snapshot below.
|
||||
_socialsBeforeEdit = _socials;
|
||||
dialog.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName is null) return;
|
||||
if (!IsAppearanceProperty(e.PropertyName)) return;
|
||||
ApplyAppearancePreview(dialog);
|
||||
};
|
||||
dialog.SaveRequested += () =>
|
||||
{
|
||||
_socials = dialog.BuildSocialsConfig();
|
||||
NotifySocialsChanged();
|
||||
CloseSocialsDrawer();
|
||||
};
|
||||
dialog.CancelRequested += CloseSocialsDrawer;
|
||||
dialog.CancelRequested += () =>
|
||||
{
|
||||
_socials = _socialsBeforeEdit;
|
||||
NotifySocialsChanged();
|
||||
OnPropertyChanged(nameof(SocialBarVisible));
|
||||
CloseSocialsDrawer();
|
||||
};
|
||||
SocialsDrawer = dialog;
|
||||
BroadcastForm.IsDrawerOpen = false;
|
||||
Ypp.IsDrawerOpen = false;
|
||||
@@ -153,6 +175,28 @@ public partial class MainViewModel
|
||||
OnPropertyChanged(nameof(SocialsDrawerOpen));
|
||||
}
|
||||
|
||||
private SocialsConfig? _socialsBeforeEdit;
|
||||
|
||||
private static bool IsAppearanceProperty(string name) => name is
|
||||
nameof(SocialsDialogViewModel.BarEnabled) or nameof(SocialsDialogViewModel.BarTextColor)
|
||||
or nameof(SocialsDialogViewModel.BarBackgroundEnabled) or nameof(SocialsDialogViewModel.BarBackgroundColor)
|
||||
or nameof(SocialsDialogViewModel.BarBackgroundOpacity) or nameof(SocialsDialogViewModel.BarTextShadow);
|
||||
|
||||
/// <summary>Paints the drawer's current appearance onto the live config and re-renders the
|
||||
/// strip. Nothing is persisted here — Save writes, Cancel restores the snapshot.</summary>
|
||||
private void ApplyAppearancePreview(SocialsDialogViewModel dialog)
|
||||
{
|
||||
if (_socials == null) return;
|
||||
_socials.BarEnabled = dialog.BarEnabled;
|
||||
_socials.BarTextColor = dialog.BarTextColor;
|
||||
_socials.BarBackgroundEnabled = dialog.BarBackgroundEnabled;
|
||||
_socials.BarBackgroundColor = dialog.BarBackgroundColor;
|
||||
_socials.BarBackgroundOpacity = dialog.BarBackgroundOpacity;
|
||||
_socials.BarTextShadow = dialog.BarTextShadow;
|
||||
NotifySocialsChanged();
|
||||
OnPropertyChanged(nameof(SocialBarVisible));
|
||||
}
|
||||
|
||||
internal void CloseSocialsDrawer()
|
||||
{
|
||||
if (!SocialsDrawerOpen) return;
|
||||
@@ -164,6 +208,8 @@ public partial class MainViewModel
|
||||
private void NotifySocialsChanged()
|
||||
{
|
||||
RenderSocialBarFrame();
|
||||
OnPropertyChanged(nameof(SocialBarTextBrush));
|
||||
OnPropertyChanged(nameof(SocialBarBackgroundBrush));
|
||||
OnPropertyChanged(nameof(HasSocials));
|
||||
OnPropertyChanged(nameof(SocialBarVisible));
|
||||
OnPropertyChanged(nameof(SocialBarDotBrush));
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Xunit;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>A missing <c>{StaticResource}</c> key does not fail the build — WPF substitutes
|
||||
/// <c>DependencyProperty.UnsetValue</c> and throws at the next layout pass. That is exactly how
|
||||
/// the app crashed on 2026-10-30: the token conversion deleted <c>YtSliderKnobHoverBrush</c>
|
||||
/// while two slider triggers still asked for it, so the first slider the creator touched
|
||||
/// (the new socials opacity slider) killed the app inside <c>Ellipse.OnRender</c>.
|
||||
///
|
||||
/// <para>This guard scans every XAML file in the app and proves each referenced key exists —
|
||||
/// either in the theme/App resources or in the same file — so the class cannot come back.</para></summary>
|
||||
public class ThemeResourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Every_StaticResource_reference_resolves_to_a_defined_key()
|
||||
{
|
||||
var root = System.IO.Path.GetDirectoryName(SourceAssert.RepoPath("App.xaml"))!;
|
||||
var globalKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var file in new[] { System.IO.Path.Combine(root, "App.xaml") }
|
||||
.Concat(Directory.GetFiles(System.IO.Path.Combine(root, "Themes"), "*.xaml")))
|
||||
globalKeys.UnionWith(KeysIn(File.ReadAllText(file)));
|
||||
|
||||
var missing = new List<string>();
|
||||
|
||||
foreach (var path in XamlFiles())
|
||||
{
|
||||
var text = File.ReadAllText(path);
|
||||
var localKeys = KeysIn(text);
|
||||
foreach (Match match in Regex.Matches(text, @"\{StaticResource ([^}]+)\}"))
|
||||
{
|
||||
var key = match.Groups[1].Value.Trim();
|
||||
if (key.StartsWith("{", StringComparison.Ordinal)) continue; // nested markup extension
|
||||
if (globalKeys.Contains(key) || localKeys.Contains(key)) continue;
|
||||
missing.Add($"{System.IO.Path.GetFileName(path)}: {key}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(missing.Count == 0,
|
||||
"StaticResource keys with no definition (WPF throws UnsetValue at layout):\n "
|
||||
+ string.Join("\n ", missing.Distinct()));
|
||||
}
|
||||
|
||||
private static HashSet<string> KeysIn(string text)
|
||||
=> Regex.Matches(text, @"x:Key=""([^""]+)""").Select(m => m.Groups[1].Value)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
private static IEnumerable<string> XamlFiles()
|
||||
{
|
||||
var root = System.IO.Path.GetDirectoryName(SourceAssert.RepoPath("App.xaml"))!;
|
||||
return Directory.EnumerateFiles(root, "*.xaml", SearchOption.AllDirectories)
|
||||
.Where(p => !p.Contains($"{System.IO.Path.DirectorySeparatorChar}obj{System.IO.Path.DirectorySeparatorChar}", StringComparison.Ordinal)
|
||||
&& !p.Contains($"{System.IO.Path.DirectorySeparatorChar}bin{System.IO.Path.DirectorySeparatorChar}", StringComparison.Ordinal)
|
||||
&& !p.Contains("ytLive.Tests", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user