feat(scoreboard): units 7+8 — the layer's properties panel (facts, colours, plate, size)

Selecting a Scoreboard layer now shows its settings, using the app's own widgets only:
- eight fact checkboxes (subscribers, watch hours, Super Chats, new members, milestones, last member,
  last Super Chat, last gift)
- text colour and plate colour via YtComboBox over BorderColorPalette (the app's one palette —
  TASK 63's rule: a picker IS the app's drop-down, never a hand-built menu)
- plate on/off, plate opacity, text size 14-24

Every change pushes through the presenter (SetStyle/SetTiles) and repaints the strip, so the preview
and the output follow immediately. One look per layout, stored on the view-model: the scoreboard is a
single billboard, not eight independent widgets.

Guard: ScoreboardPropertiesTests asserts the panel is gated on SelectedElement.IsScoreboard, binds
all eight facts and the four look controls, uses YtComboBox/YtCheckBox only, and that the VM surface
exists for each name (a typo would otherwise bind to nothing in silence).
This commit is contained in:
2026-10-30 14:41:31 -07:00
parent deb74529a6
commit 534f56a7c4
3 changed files with 180 additions and 0 deletions
+55
View File
@@ -447,6 +447,61 @@
</DockPanel>
</StackPanel>
<!-- Scoreboard config (creator 2026-10-30): the ego wall's billboard —
which eight facts show, and how the strip is painted. -->
<StackPanel Visibility="{Binding SelectedElement.IsScoreboard, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Facts" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
<WrapPanel Margin="10,0,0,0">
<CheckBox Style="{StaticResource YtCheckBox}" Content="Subscribers" Margin="0,0,10,4"
IsChecked="{Binding ScoreboardShowSubscribers, Mode=TwoWay}"/>
<CheckBox Style="{StaticResource YtCheckBox}" Content="Watch hours" Margin="0,0,10,4"
IsChecked="{Binding ScoreboardShowWatchHours, Mode=TwoWay}"/>
<CheckBox Style="{StaticResource YtCheckBox}" Content="Super Chats" Margin="0,0,10,4"
IsChecked="{Binding ScoreboardShowSuperChats, Mode=TwoWay}"/>
<CheckBox Style="{StaticResource YtCheckBox}" Content="New members" Margin="0,0,10,4"
IsChecked="{Binding ScoreboardShowNewMembers, Mode=TwoWay}"/>
<CheckBox Style="{StaticResource YtCheckBox}" Content="Milestones" Margin="0,0,10,4"
IsChecked="{Binding ScoreboardShowMilestones, Mode=TwoWay}"/>
<CheckBox Style="{StaticResource YtCheckBox}" Content="Last member" Margin="0,0,10,4"
IsChecked="{Binding ScoreboardShowLastMember, Mode=TwoWay}"/>
<CheckBox Style="{StaticResource YtCheckBox}" Content="Last Super Chat" Margin="0,0,10,4"
IsChecked="{Binding ScoreboardShowLastSuperChat, Mode=TwoWay}"/>
<CheckBox Style="{StaticResource YtCheckBox}" Content="Last gift" Margin="0,0,10,4"
IsChecked="{Binding ScoreboardShowLastGift, Mode=TwoWay}"/>
</WrapPanel>
<TextBlock Text="Text colour" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,6,0,2"/>
<ComboBox Style="{StaticResource YtComboBox}" Height="24" FontSize="11" Margin="10,0,0,6"
ItemsSource="{Binding ScoreboardColorOptions}" DisplayMemberPath="Name"
SelectedValuePath="Hex" SelectedValue="{Binding ScoreboardTextColor, Mode=TwoWay}"/>
<CheckBox Style="{StaticResource YtCheckBox}" Content="Background plate" Margin="10,0,0,4"
IsChecked="{Binding ScoreboardBackgroundEnabled, Mode=TwoWay}"/>
<ComboBox Style="{StaticResource YtComboBox}" Height="24" FontSize="11" Margin="10,0,0,6"
ItemsSource="{Binding ScoreboardColorOptions}" DisplayMemberPath="Name"
SelectedValuePath="Hex" SelectedValue="{Binding ScoreboardBackgroundColor, Mode=TwoWay}"
IsEnabled="{Binding ScoreboardBackgroundEnabled}"/>
<DockPanel Margin="10,0,0,6" IsEnabled="{Binding ScoreboardBackgroundEnabled}">
<TextBlock DockPanel.Dock="Left" Text="Opacity" Foreground="{StaticResource Brush.OnSurfaceVariant}"
FontSize="11" Width="60" VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Width="42" TextAlignment="Right" FontSize="11"
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"
Text="{Binding ScoreboardBackgroundOpacity, StringFormat={}{0:P0}}"/>
<Slider Minimum="0" Maximum="1" SmallChange="0.05" LargeChange="0.1"
Value="{Binding ScoreboardBackgroundOpacity, Mode=TwoWay}" VerticalAlignment="Center"/>
</DockPanel>
<DockPanel Margin="10,0,0,0">
<TextBlock DockPanel.Dock="Left" Text="Text size" Foreground="{StaticResource Brush.OnSurfaceVariant}"
FontSize="11" Width="60" VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Right" Width="42" TextAlignment="Right" FontSize="11"
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"
Text="{Binding ScoreboardFontSize, StringFormat={}{0:0}}"/>
<Slider Minimum="14" Maximum="24" SmallChange="1" LargeChange="2"
Value="{Binding ScoreboardFontSize, Mode=TwoWay}" VerticalAlignment="Center"/>
</DockPanel>
</StackPanel>
<!-- Countdown config -->
<StackPanel Visibility="{Binding SelectedElement.IsCountdown, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Timer (minutes)" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
+71
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Media;
@@ -59,6 +60,76 @@ public partial class MainViewModel
/// (unit 7) — kept here so the panel binds one object instead of six primitives.</summary>
internal ScoreboardStyle ScoreboardStyleState => _scoreboardStyle;
// ─── Properties panel surface (per layout: one scoreboard look) ────────────
public string ScoreboardTextColor
{
get => _scoreboardStyle.TextColor;
set { _scoreboardStyle = _scoreboardStyle with { TextColor = value }; PushStyle(); }
}
public bool ScoreboardBackgroundEnabled
{
get => _scoreboardStyle.BackgroundEnabled;
set { _scoreboardStyle = _scoreboardStyle with { BackgroundEnabled = value }; PushStyle(); }
}
public string ScoreboardBackgroundColor
{
get => _scoreboardStyle.BackgroundColor;
set { _scoreboardStyle = _scoreboardStyle with { BackgroundColor = value }; PushStyle(); }
}
public double ScoreboardBackgroundOpacity
{
get => _scoreboardStyle.BackgroundOpacity;
set
{
_scoreboardStyle = _scoreboardStyle with { BackgroundOpacity = Math.Clamp(value, 0, 1) };
PushStyle();
}
}
public double ScoreboardFontSize
{
get => _scoreboardStyle.FontSize;
set
{
_scoreboardStyle = _scoreboardStyle with
{
FontSize = Math.Clamp(value, ScoreboardLayout.FloorFontSize, ScoreboardLayout.MaxFontSize),
};
PushStyle();
}
}
/// <summary>The colours offered for text and plate: the app's one palette.</summary>
public IReadOnlyList<NamedColor> ScoreboardColorOptions => BorderColorPalette.All;
public bool ScoreboardShowSubscribers { get => _scoreboardTiles.Subscribers; set => SetTile(t => t.Subscribers = value); }
public bool ScoreboardShowWatchHours { get => _scoreboardTiles.WatchHours; set => SetTile(t => t.WatchHours = value); }
public bool ScoreboardShowSuperChats { get => _scoreboardTiles.SuperChats; set => SetTile(t => t.SuperChats = value); }
public bool ScoreboardShowNewMembers { get => _scoreboardTiles.NewMembers; set => SetTile(t => t.NewMembers = value); }
public bool ScoreboardShowMilestones { get => _scoreboardTiles.Milestones; set => SetTile(t => t.Milestones = value); }
public bool ScoreboardShowLastMember { get => _scoreboardTiles.LastMember; set => SetTile(t => t.LastMember = value); }
public bool ScoreboardShowLastSuperChat { get => _scoreboardTiles.LastSuperChat; set => SetTile(t => t.LastSuperChat = value); }
public bool ScoreboardShowLastGift { get => _scoreboardTiles.LastGift; set => SetTile(t => t.LastGift = value); }
private void SetTile(Action<ScoreboardTiles> change)
{
change(_scoreboardTiles);
_scoreboardPresenter?.SetTiles(_scoreboardTiles);
RefreshScoreboardPreviews();
OnPropertyChanged(nameof(ScoreboardTilesState));
}
private void PushStyle()
{
_scoreboardPresenter?.SetStyle(_scoreboardStyle);
RefreshScoreboardPreviews();
OnPropertyChanged(nameof(ScoreboardStyleState));
}
/// <summary>Publishes the strip to every scoreboard layer's preview element. The pump thread
/// must never touch a WriteableBitmap, so this runs on the UI thread only.</summary>
private void RefreshScoreboardPreviews()
+54
View File
@@ -0,0 +1,54 @@
using System.Reflection;
using Xunit;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>The scoreboard's look and its eight fact switches live in the LAYER PROPERTIES, and the
/// panel must use the app's widgets — a hand-painted control is the TASK 58/63 defect, and the
/// guard that catches it is here because the type-based sweep cannot see a bespoke picker.</summary>
public class ScoreboardPropertiesTests
{
[Fact]
public void The_scoreboard_properties_are_themed_controls_bound_to_the_layer_surface()
{
var panel = SourceAssert.Text("Controls", "LeftPanel.xaml");
// Gated on the selected layer being a scoreboard.
Assert.Contains("SelectedElement.IsScoreboard", panel);
// The eight fact switches.
foreach (var binding in new[]
{
"ScoreboardShowSubscribers", "ScoreboardShowWatchHours", "ScoreboardShowSuperChats",
"ScoreboardShowNewMembers", "ScoreboardShowMilestones", "ScoreboardShowLastMember",
"ScoreboardShowLastSuperChat", "ScoreboardShowLastGift",
})
Assert.Contains(binding, panel);
// Look controls: text colour, plate colour, plate opacity, text size.
foreach (var binding in new[]
{
"ScoreboardTextColor", "ScoreboardBackgroundColor",
"ScoreboardBackgroundOpacity", "ScoreboardFontSize",
})
Assert.Contains(binding, panel);
// Theme widgets only: the colour pickers are the app's drop-down (YtComboBox), never a
// hand-built menu, and the toggles are YtCheckBox.
var block = panel[panel.IndexOf("Scoreboard config", System.StringComparison.Ordinal)..];
block = block[..block.IndexOf("<!-- Countdown config -->", System.StringComparison.Ordinal)];
Assert.Contains("Style=\"{StaticResource YtComboBox}\"", block);
Assert.Contains("Style=\"{StaticResource YtCheckBox}\"", block);
Assert.DoesNotContain("ContextMenu", block);
// The VM surface exists for every binding above (a typo would bind to nothing silently).
var type = typeof(MainViewModel);
foreach (var name in new[]
{
"ScoreboardTextColor", "ScoreboardBackgroundColor", "ScoreboardBackgroundOpacity",
"ScoreboardFontSize", "ScoreboardColorOptions", "ScoreboardShowSubscribers",
})
Assert.NotNull(type.GetProperty(name));
}
}