diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index 6a6e8c8..03437a9 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -208,6 +208,9 @@
+
+
+
diff --git a/Models/ScoreboardHealer.cs b/Models/ScoreboardHealer.cs
new file mode 100644
index 0000000..2eabd14
--- /dev/null
+++ b/Models/ScoreboardHealer.cs
@@ -0,0 +1,56 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ytLive.Models;
+
+/// Keeps exactly one Scoreboard layer in EVERY scene.
+///
+/// Creator ruling (2026-10-30): "YT_ACTIVITY_TICKER … is available to all scenes." The
+/// scoreboard is not something to add five times — it exists once per scene like the Background and
+/// the Brand logo, and a creator turns it off per scene with the ordinary eye toggle. Removing it is
+/// therefore not a delete: it heals on the next load.
+///
+/// Pure over a scene collection, so the rule is testable without a view-model.
+public static class ScoreboardHealer
+{
+ public const string LayerName = "Scoreboard";
+
+ /// The strip's default box: full width, a strip's height, and its Y for the bottom
+ /// edge. Geometry only — the EDGE itself is resolved against the social bar at render time.
+ public const double StripHeight = 56;
+ public const double BottomY = 1080 - StripHeight;
+
+ /// Ensures one scoreboard per scene, appended as the TOPMOST layer: it is a full-width
+ /// strip drawn over the scene, and inserting it above the background would break the Brand
+ /// logo's documented invariant ("directly above the background"). Returns true when anything
+ /// changed, so the caller can save.
+ public static bool Ensure(IEnumerable scenes)
+ {
+ var changed = false;
+ foreach (var scene in scenes)
+ {
+ var existing = scene.Elements.OfType().Where(s => s.IsScoreboard).ToList();
+
+ // More than one: keep the first, drop the rest.
+ foreach (var extra in existing.Skip(1))
+ {
+ scene.Elements.Remove(extra);
+ changed = true;
+ }
+ if (existing.Count > 0) continue;
+
+ scene.Elements.Add(new Source
+ {
+ Name = LayerName,
+ Type = SourceType.Scoreboard,
+ IsEnabled = true,
+ X = 0,
+ Y = BottomY,
+ Width = 1920,
+ Height = StripHeight,
+ });
+ changed = true;
+ }
+ return changed;
+ }
+}
diff --git a/ViewModels/MainViewModel.Background.cs b/ViewModels/MainViewModel.Background.cs
index 1ad9ab1..dd53414 100644
--- a/ViewModels/MainViewModel.Background.cs
+++ b/ViewModels/MainViewModel.Background.cs
@@ -439,6 +439,7 @@ public partial class MainViewModel
}
SeedOnAirImage();
EnsureBrandLogos();
+ EnsureScoreboards();
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
OnPropertyChanged(nameof(LayerCount));
}
diff --git a/ViewModels/MainViewModel.Scoreboard.cs b/ViewModels/MainViewModel.Scoreboard.cs
index b33cebe..9889548 100644
--- a/ViewModels/MainViewModel.Scoreboard.cs
+++ b/ViewModels/MainViewModel.Scoreboard.cs
@@ -45,6 +45,18 @@ public partial class MainViewModel
RefreshScoreboardPreviews();
}
+ /// One Scoreboard layer in every scene, healed on load — "available to all scenes"
+ /// (creator ruling 2026-10-30). Per-scene visibility is the ordinary eye toggle.
+ internal void EnsureScoreboards()
+ {
+ if (ScoreboardHealer.Ensure(Scenes))
+ {
+ foreach (var scene in Scenes) _sceneGraph.InvalidateBake(scene);
+ RefreshScoreboardPreviews();
+ OnPropertyChanged(nameof(LayerCount));
+ }
+ }
+
private void InitScoreboard()
{
_scoreboardPresenter = new ScoreboardPresenter(_scoreboard, _scoreboardStyle)
diff --git a/ytLive.Tests/ScoreboardHealerTests.cs b/ytLive.Tests/ScoreboardHealerTests.cs
new file mode 100644
index 0000000..79e287b
--- /dev/null
+++ b/ytLive.Tests/ScoreboardHealerTests.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+using System.Linq;
+using Xunit;
+using ytLive.Models;
+
+namespace ytLive.Tests;
+
+/// Creator ruling (2026-10-30): the Scoreboard layer — YT_ACTIVITY_TICKER — is AVAILABLE TO
+/// ALL SCENES. It therefore heals into every scene like the Background and Brand logo, and a scene
+/// can never end up with two of them.
+public class ScoreboardHealerTests
+{
+ private static Scene SceneWith(string name)
+ {
+ var scene = new Scene { Name = name };
+ scene.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true });
+ scene.Elements.Add(new Source { Name = "Brand logo", Type = SourceType.Image, IsBrandLogo = true });
+ return scene;
+ }
+
+ [Fact]
+ public void Every_scene_gets_exactly_one_scoreboard_below_the_background()
+ {
+ var scenes = new List
+ {
+ SceneWith(SceneCatalog.Starting), SceneWith(SceneCatalog.Live), SceneWith(SceneCatalog.Brb),
+ };
+
+ Assert.True(ScoreboardHealer.Ensure(scenes));
+ foreach (var scene in scenes)
+ {
+ var boards = scene.Elements.OfType().Where(s => s.IsScoreboard).ToList();
+ Assert.Single(boards);
+ Assert.Equal("Scoreboard", boards[0].Name);
+ Assert.Equal(0, boards[0].X);
+ Assert.Equal(1920, boards[0].Width);
+ Assert.Equal(ScoreboardHealer.StripHeight, boards[0].Height);
+ // Topmost: a full-width strip over the scene, and the Brand logo keeps its documented
+ // place directly above the background.
+ Assert.Equal(scene.Elements.Count - 1, scene.Elements.IndexOf(boards[0]));
+ Assert.Equal("Brand logo", scene.Elements[1].Name);
+ }
+
+ // Idempotent: a second pass changes nothing (no duplicate strips, no churn).
+ Assert.False(ScoreboardHealer.Ensure(scenes));
+ foreach (var scene in scenes) Assert.Single(scene.Elements.OfType().Where(s => s.IsScoreboard));
+
+ // A scene that somehow has two is healed back to one.
+ scenes[0].Elements.Add(new Source { Name = "Scoreboard2", Type = SourceType.Scoreboard });
+ Assert.True(ScoreboardHealer.Ensure(scenes));
+ Assert.Single(scenes[0].Elements.OfType().Where(s => s.IsScoreboard));
+ }
+}