diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index ceb809d..30a7499 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -29,6 +29,7 @@
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
+
@@ -37,11 +38,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -1072,7 +1122,7 @@
-
+
@@ -1194,12 +1244,36 @@
VirtualizingPanel.VirtualizationMode="Recycling">
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/HANDOFF.md b/HANDOFF.md
index 9357c11..8f8570c 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,40 +1,30 @@
-# HANDOFF — 2026-11-05 (TASK 64 — green/blue screen for clips)
+# HANDOFF — 2026-11-05 (alert stack + media/chroma fixes)
## State
-- `main`. TASK 21 complete (video file layer + its soundtrack). TASK 64 shipped through slice E:
- a clip with a green or blue backdrop is **offered** a key on add ("Looks like a green screen —
- Remove it"), accepting it keys the background **inside the decode pipe** (bundled ffmpeg
- `colorkey`/`despill`), with Amount/Softness/Fringe in the panel and the settings persisted.
- Sessions are keyed by **path + look**, so identical looks share one decoder.
-- Detection stays silent on lawns/pitches (confidence = coverage × hue tightness) and ignores
- letterbox bars. It never acts on its own.
-- Slice F shipped too: **App Settings → Video clips → "Auto Green/Blue Screen"** (off by default,
- high confidence only, and the panel states when a key was automatic) and **alert clips** key through
- the same settings block (one alert box per layout).
-- Tests: `ChromaKeyDetectorTests` + `ChromaKeyApplyTests` (pipe asks ffmpeg to key; offer → accept →
- keyed session feeds the resolver; survives save; auto-apply; alert clip keyed). App build 0 warnings.
-- **TASK 49 retired (creator ruling):** YouTube owns chat moderation — blocked words and hold-for-review
- run before the API. What we owed it was not a wordlist but not painting what YouTube removed: the chat
- parser now skips `tombstone` (a deleted message's text is never sent) and `hasDisplayContent:false`.
- Same commit fixed a live bug: Super Chat read `displayString` instead of `amountDisplayString`, so every
- amount fell back to a recomposed string.
+- `main`. TASK 21 (video layer + soundtrack), TASK 64 (green/blue screen) and TASK 32·4 (fragmented MP4)
+ are complete; TASK 49 is retired (YouTube owns chat moderation — we just don't paint tombstones).
+- **Left panel alert stack** shipped: Steam licence indicator pinned at row 1 (`Entitlement 30 fps ·
+ Maximum 60 fps`, pin → close once owned, permanent while owned), event rows LIFO beneath, click →
+ About or the Logs tab with that event's lines painted. Detail: `docs/ai/ui-surface.md`.
+- Video layer fixes this session: the preview-pane trigger whitelist (a video layer was invisible), a
+ quadratic frame copy that capped 1080p playback at 2 fps, and deadline pacing for video *and* audio.
+- Tests: 575 green, 1m50s; `FAST=1 ./scripts/verify.sh` = 527 in ~62s.
## Next action
-- TASK 64 is complete. Next queued: TASK 49 (chat profanity filter). Day 3's TASK 32/33 resilience slices remain outstanding —
- 32·4 (fragmented MP4) is the smallest.
+- **TASK 32·1 (blip retry)** — it pushes its episodes into the alert stack. Then 32·5 (ingest pre-flight),
+ then 32·2/32·3 (countdown + Back on air) which need YOUR measurement take: pull the network on a
+ private stream and time how long YouTube holds the broadcast.
+- Open loop: `32·4` is proven by construction only — kill ffmpeg mid-recording and play the file.
- Do not push absent a checkpoint request.
## Landmines
-- **The preview pane draws frame-source layers from a whitelist of DataTriggers.** A layer type with
- no trigger is invisible in the preview while its decode looks healthy (alert box 2026-09-26, the
- scoreboard, and the video layer 2026-11-05). Add the trigger with the layer type; pin it with a
- test that asserts the bound Image is Visible (`MediaPreviewDisplayTests`).
-- The old physical mouse-drag test is **deleted**; the drag arithmetic it covered is now pure and
- tested in `DragReorderMathTests`. Nothing in the suite drives the real cursor any more.
+- **The preview pane draws frame-source layers from a whitelist of DataTriggers.** A layer type with no
+ trigger is invisible in the preview while its decode looks healthy (alert box 2026-09-26, scoreboard,
+ video layer 2026-11-05). Add the trigger with the layer type; pin it with `MediaPreviewDisplayTests`.
+- Nothing in the suite drives the real cursor any more; the drag arithmetic is pure (`DragReorderMathTests`).
- Integration tests constructing `MainWindow` must set a temporary `LayoutPathOverride` and clear it in `finally`.
-- Media sessions are keyed by **path + look** (`MainViewModel.MediaSessionKey`); never acquire by raw
- path, and release through `ReleaseMediaIfUnused`.
+- Media sessions are keyed by **path + look** (`MainViewModel.MediaSessionKey`); never acquire by raw path.
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
diff --git a/Helpers/LogInRangeConverter.cs b/Helpers/LogInRangeConverter.cs
new file mode 100644
index 0000000..df09169
--- /dev/null
+++ b/Helpers/LogInRangeConverter.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Globalization;
+using System.Windows.Data;
+
+namespace ytLive.Helpers;
+
+///
+/// True when a log entry's sequence falls inside the range a clicked alert points at. Two numbers
+/// rather than a membership collection: the log list is virtualised, and rows that are already
+/// realised do not re-evaluate when a collection changes — but they do when a bound number changes.
+///
+public sealed class LogInRangeConverter : IMultiValueConverter
+{
+ public object Convert(object[] values, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (values.Length < 3) return false;
+ if (values[0] is not long sequence) return false;
+ if (values[1] is not long from || values[2] is not long to) return false;
+ if (from == 0 && to == 0) return false;
+ return sequence >= from && sequence <= to;
+ }
+
+ public object[] ConvertBack(object value, Type[] targetTypes, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 8704b38..2482785 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -145,13 +145,6 @@
ItemContainerStyle="{StaticResource YtQualityComboBoxItem}"
ToolTip="{Binding ResolutionHelp}"
IsEnabled="{Binding CanChangeQuality}"/>
-
-
-
diff --git a/Models/AttentionAlert.cs b/Models/AttentionAlert.cs
new file mode 100644
index 0000000..1b3a2af
--- /dev/null
+++ b/Models/AttentionAlert.cs
@@ -0,0 +1,35 @@
+namespace ytLive.Models;
+
+///
+/// One row in the left panel's alert stack (creator design, 2026-11-05). The stack sits above the
+/// panel's content and pushes it down, so an alert is seen without hunting for a tab.
+/// Pinned rows are standing state, not errors to clear: they show a pin instead of a
+/// close control and are only removed when the state itself changes (the Steam licence indicator
+/// becomes dismissible — and its dismissal permanent — once ownership is verified).
+/// Clicking a row runs its action: the licence indicator opens About; an event alert opens the
+/// Logs tab with that event's lines highlighted.
+///
+public sealed record AttentionAlert(
+ string Id,
+ string Label,
+ string Text,
+ bool IsPinned,
+ AttentionAlertAction Action,
+ long LogFrom = 0,
+ long LogTo = 0)
+{
+ /// True while a close control should be shown (a dismissible row).
+ public bool CanDismiss => !IsPinned;
+
+ /// True while the row should show the pin glyph instead of a close control.
+ public bool ShowsPin => IsPinned;
+}
+
+public enum AttentionAlertAction
+{
+ /// Open the About panel (the licence indicator).
+ About,
+
+ /// Open the Logs tab and highlight this event's lines.
+ Logs,
+}
diff --git a/Services/Entitlement/SteamEntitlement.cs b/Services/Entitlement/SteamEntitlement.cs
index 76fbe77..faff0ca 100644
--- a/Services/Entitlement/SteamEntitlement.cs
+++ b/Services/Entitlement/SteamEntitlement.cs
@@ -52,7 +52,7 @@ public sealed class SteamEntitlement : IEntitlement
if (_paidAppId == 0)
{
_cached = false;
- _errorMessage = "The paid Steam app ID is not configured for this build. Contact the creator or support.";
+ _errorMessage = "Steam licensing is not configured in this build.";
return false;
}
if (_cached is { } known) return known;
diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs
index 65652e0..3397471 100644
--- a/Services/LayoutStore.Settings.cs
+++ b/Services/LayoutStore.Settings.cs
@@ -50,6 +50,13 @@ public partial class LayoutStore : IDisposable
/// to the box centre while it is dragged).
/// TASK 64: key a detected green/blue screen without asking. Off by default — the
/// app offers, the creator accepts; this switch is for the creator who is tired of clicking.
+ /// Creator closed the Steam licence indicator while ownership was verified. Honoured only
+ /// while Steam keeps confirming ownership — losing it brings the indicator back (2026-11-05).
+ public bool LoadSteamLicenceIndicatorDismissed() => GetSetting("Steam.LicenceIndicatorDismissed") == "1";
+
+ public void SaveSteamLicenceIndicatorDismissed(bool dismissed)
+ => UpsertSetting("Steam.LicenceIndicatorDismissed", dismissed ? "1" : "0");
+
public bool LoadAutoChromaScreen() => GetSetting("Video.AutoChromaScreen") == "1";
public void SaveAutoChromaScreen(bool enabled) => UpsertSetting("Video.AutoChromaScreen", enabled ? "1" : "0");
diff --git a/ViewModels/MainViewModel.Alerts.cs b/ViewModels/MainViewModel.Alerts.cs
new file mode 100644
index 0000000..e718f8e
--- /dev/null
+++ b/ViewModels/MainViewModel.Alerts.cs
@@ -0,0 +1,138 @@
+using System.Collections.ObjectModel;
+using System.Linq;
+using ytLive.Helpers;
+using ytLive.Models;
+
+namespace ytLive.ViewModels;
+
+///
+/// The left panel's alert stack (creator design, 2026-11-05): standing state on top, transient events
+/// beneath it, newest first, pushing the panel's content down. Only red rows belong here — the panel
+/// is where "something needs you" lives, so it never fills with chatter.
+/// Row 1 is the Steam licence indicator and nothing displaces it. It is pinned while
+/// higher output tiers are unavailable, and becomes dismissible — permanently — once ownership is
+/// verified. If ownership is later lost, it comes back as a pinned indicator, because that is a change
+/// of state rather than a preference.
+///
+public partial class MainViewModel
+{
+ private const string SteamLicenceAlertId = "steam-licence";
+
+ private readonly ObservableCollection _attentionAlerts = new();
+ private bool _steamLicenceDismissed;
+
+ public ObservableCollection AttentionAlerts => _attentionAlerts;
+
+ public bool HasAttentionAlerts => _attentionAlerts.Count > 0;
+
+ public RelayCommand DismissAlertCommand => new(DismissAlert);
+
+ public RelayCommand ActivateAlertCommand => new(ActivateAlert);
+
+ /// The licence indicator's text: what this build is entitled to, and the maximum.
+ /// A status readout — deliberately not a description of what is missing (creator ruling: the
+ /// resolution picker explains that).
+ private string SteamLicenceText()
+ {
+ if (QualityOptions.Length == 0) return "Entitlement 30 fps · Maximum 60 fps";
+
+ var max = QualityOptions.Max(option => option.Fps);
+ var entitled = QualityOptions
+ .Where(option => HasProOutput || !option.RequiresPro)
+ .Select(option => option.Fps)
+ .DefaultIfEmpty(max)
+ .Max();
+ return $"Entitlement {entitled} fps · Maximum {max} fps";
+ }
+
+ /// Recomputes row 1 in place. Called at startup and whenever ownership changes.
+ private void RefreshSteamLicenceAlert()
+ {
+ var existing = _attentionAlerts.FirstOrDefault(alert => alert.Id == SteamLicenceAlertId);
+ var hidden = IsAppOwned && _steamLicenceDismissed;
+
+ if (hidden)
+ {
+ if (existing != null) _attentionAlerts.Remove(existing);
+ RaiseAttentionChanged();
+ return;
+ }
+
+ var alert = new AttentionAlert(
+ SteamLicenceAlertId,
+ "STEAM LICENCE",
+ SteamLicenceText(),
+ IsPinned: !IsAppOwned,
+ AttentionAlertAction.About);
+
+ var index = existing == null ? -1 : _attentionAlerts.IndexOf(existing);
+ if (index < 0) _attentionAlerts.Insert(0, alert);
+ else _attentionAlerts[index] = alert; // same row, updated state
+ RaiseAttentionChanged();
+ }
+
+ /// Pushes an event alert. Newest sits directly under the licence indicator; older ones
+ /// stack beneath, so the newest is the one nearest the eye.
+ private AttentionAlert? PushAttentionAlert(string id, string label, string text, long logFrom, long logTo)
+ {
+ var alert = new AttentionAlert(id, label, text, IsPinned: false, AttentionAlertAction.Logs, logFrom, logTo);
+ var insertAt = _attentionAlerts.Count(alert => alert.Id == SteamLicenceAlertId);
+ var existing = _attentionAlerts.FirstOrDefault(candidate => candidate.Id == id);
+ if (existing != null)
+ {
+ // Same event advancing (reconnecting attempt 2 → 3): update the row, never duplicate it.
+ _attentionAlerts[_attentionAlerts.IndexOf(existing)] = alert;
+ }
+ else
+ {
+ _attentionAlerts.Insert(insertAt, alert);
+ }
+ RaiseAttentionChanged();
+ return alert;
+ }
+
+ /// Test seam: pushes an event row without a stream session behind it.
+ internal AttentionAlert? PushAttentionAlertForTest(string id, string label, string text, long logFrom, long logTo)
+ => PushAttentionAlert(id, label, text, logFrom, logTo);
+
+ private void RemoveAttentionAlert(string id)
+ {
+ var existing = _attentionAlerts.FirstOrDefault(alert => alert.Id == id);
+ if (existing == null) return;
+ _attentionAlerts.Remove(existing);
+ RaiseAttentionChanged();
+ }
+
+ private void DismissAlert(AttentionAlert? alert)
+ {
+ if (alert == null || alert.IsPinned) return;
+
+ if (alert.Id == SteamLicenceAlertId)
+ {
+ // Permanent for this profile: the creator said "I have seen it". Only honoured while
+ // ownership holds — RefreshSteamLicenceAlert ignores it when Steam stops confirming.
+ _steamLicenceDismissed = true;
+ _layoutStore.SaveSteamLicenceIndicatorDismissed(true);
+ }
+
+ RemoveAttentionAlert(alert.Id);
+ }
+
+ private void ActivateAlert(AttentionAlert? alert)
+ {
+ if (alert == null) return;
+
+ if (alert.Action == AttentionAlertAction.About)
+ {
+ ShowAbout();
+ return;
+ }
+
+ // Event alert: open the log and paint this event's lines, so the alert is an entry point to
+ // the detail rather than a dead end.
+ HighlightLogRange(alert.LogFrom, alert.LogTo);
+ SelectLogsTab(true);
+ }
+
+ private void RaiseAttentionChanged() => OnPropertyChanged(nameof(HasAttentionAlerts));
+}
diff --git a/ViewModels/MainViewModel.Logs.cs b/ViewModels/MainViewModel.Logs.cs
index 3dfa5b9..6c21fa7 100644
--- a/ViewModels/MainViewModel.Logs.cs
+++ b/ViewModels/MainViewModel.Logs.cs
@@ -16,6 +16,19 @@ public partial class MainViewModel
public ObservableCollection CurrentLaunchLogEntries => _currentLaunchLogEntries;
public ObservableCollection PinnedLogEntries => _pinnedLogEntries;
+
+ // The event range a clicked alert points at. A range rather than a collection: the log list is
+ // virtualised, and a membership change would not re-evaluate realised rows — two numbers do.
+ private long _highlightedLogFrom;
+ private long _highlightedLogTo;
+ public long HighlightedLogFrom => _highlightedLogFrom;
+ public long HighlightedLogTo => _highlightedLogTo;
+
+ private void HighlightLogRange(long from, long to)
+ {
+ SetProperty(ref _highlightedLogFrom, from, nameof(HighlightedLogFrom));
+ SetProperty(ref _highlightedLogTo, to, nameof(HighlightedLogTo));
+ }
public bool HasPinnedLogEntries => _pinnedLogEntries.Count > 0;
public RelayCommand AcknowledgeLogEntryCommand => new(AcknowledgeLogEntry);
diff --git a/ViewModels/MainViewModel.OutputProfile.cs b/ViewModels/MainViewModel.OutputProfile.cs
index 0ea30b3..704eeae 100644
--- a/ViewModels/MainViewModel.OutputProfile.cs
+++ b/ViewModels/MainViewModel.OutputProfile.cs
@@ -25,6 +25,7 @@ public partial class MainViewModel
EntitlementErrorMessage = _entitlement.ErrorMessage;
if (!changed) return;
OnPropertyChanged(nameof(HasProOutput));
+ RefreshSteamLicenceAlert();
if (!HasProOutput && _selectedQuality is { RequiresPro: true } && CanChangeQuality)
SelectedQuality = QualityOptions.Single(option => option.Display == OutputProfile.FreeFallbackName);
}
@@ -77,6 +78,7 @@ public partial class MainViewModel
if (!DevBuild.IsDevelopment) return;
if (!SetProperty(ref _developerMode, value)) return;
OnPropertyChanged(nameof(HasProOutput));
+ RefreshSteamLicenceAlert();
}
}
}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 45e84eb..b79e03e 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -350,6 +350,8 @@ public partial class MainViewModel : ViewModelBase
AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
_zoomFollowsBox = _layoutStore.LoadZoomFollowBox();
_autoChromaScreen = _layoutStore.LoadAutoChromaScreen();
+ _steamLicenceDismissed = _layoutStore.LoadSteamLicenceIndicatorDismissed();
+ RefreshSteamLicenceAlert();
MonoOutput = _layoutStore.LoadMonoOutput(); // applied to the mixer by the setter
_musicPlayer.UserGain = (float)Helpers.GainDb.FromDb(TraxDefaultDb); // background by default
InitScoreboard();
diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md
index 094cbf0..568c545 100644
--- a/docs/ai/ui-surface.md
+++ b/docs/ai/ui-surface.md
@@ -1,3 +1,22 @@
+## Left panel alert stack (2026-11-05)
+
+A stack of red rows at the top of the left panel, **above both mode grids** (so it shows while editing
+and while live) and pushing the panel's content down. Only red rows belong there — it is the surface for
+"something needs you", never chatter.
+
+- **Row 1 is always the Steam licence indicator** and nothing displaces it. It reads as status, not a
+ warning: `Entitlement 30 fps · Maximum 60 fps`, both numbers derived from `QualityOptions` and
+ `HasProOutput`. It shows a **pin** (standing indicator, no close control) while higher tiers are
+ unavailable, and a **close control once ownership is verified** — that close is persisted
+ (`Steam.LicenceIndicatorDismissed`) and permanent *while ownership holds*; losing ownership restores
+ the row as pinned, because that is a change of state rather than a preference. Click → About.
+- **Event rows stack beneath it, newest first**, one row per episode (a reconnect advancing its state
+ updates its own row rather than adding another). Each is dismissible. Click → the Logs tab opens with
+ that event's sequence range painted red (`HighlightedLogFrom/To` + `LogInRangeConverter`; two numbers
+ rather than a collection because the log list is virtualised and realised rows do not re-evaluate on
+ membership changes).
+- The old red panel under the quality picker is **gone** — this stack replaced it.
+
## Preview pane — frame-source layers are a WHITELIST
`PreviewPane.xaml`'s per-layer `Image` is **Collapsed by default** and switched on by one DataTrigger
diff --git a/ytLive.Tests/AboutHubTests.cs b/ytLive.Tests/AboutHubTests.cs
index bb923b6..2a6dfd9 100644
--- a/ytLive.Tests/AboutHubTests.cs
+++ b/ytLive.Tests/AboutHubTests.cs
@@ -96,10 +96,13 @@ public sealed class AboutHubTests
var lockedTier = vm.QualityOptions.First(option => option.Fps == 60);
Assert.Same(freeTier, vm.SelectedQuality);
- var errorPanel = (Border)window.FindName("EntitlementErrorPanel");
- var errorText = (TextBlock)window.FindName("EntitlementErrorText");
- Assert.Equal(Visibility.Visible, errorPanel.Visibility);
- Assert.Contains("Open Steam, sign in", errorText.Text);
+ // The old red panel under the quality picker is gone (2026-11-05): the licence state
+ // lives in the left panel's alert stack now, as a pinned status indicator.
+ Assert.Null(window.FindName("EntitlementErrorPanel"));
+ var licenceRow = vm.AttentionAlerts.Single(alert => alert.Label == "STEAM LICENCE");
+ Assert.True(licenceRow.IsPinned);
+ Assert.Contains("Entitlement 30 fps", licenceRow.Text);
+ Assert.Contains("Maximum 60 fps", licenceRow.Text);
var picker = (ComboBox)window.FindName("QualityPicker");
Assert.True(vm.CanChangeQuality);
diff --git a/ytLive.Tests/AttentionAlertStackTests.cs b/ytLive.Tests/AttentionAlertStackTests.cs
new file mode 100644
index 0000000..2f5d09c
--- /dev/null
+++ b/ytLive.Tests/AttentionAlertStackTests.cs
@@ -0,0 +1,149 @@
+using System;
+using System.IO;
+using System.Linq;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services.Entitlement;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// The left panel's alert stack (creator design, 2026-11-05). Row 1 is the Steam licence indicator and
+/// nothing displaces it: pinned (no close) while higher tiers are unavailable, closable — permanently —
+/// once ownership is verified, and back as pinned if ownership is later lost.
+/// Event rows stack beneath it, newest first, each dismissible, and clicking one opens the Logs
+/// tab with that event's lines painted (the range is what the row carries).
+///
+[Collection("RealApp")]
+public sealed class AttentionAlertStackTests
+{
+ private readonly RealAppHost _app;
+
+ public AttentionAlertStackTests(RealAppHost app) => _app = app;
+
+ [Fact]
+ public void LicenceIndicator_IsPinnedFirst_AndShowsStatusNotAPunishment()
+ {
+ WithVm(vm =>
+ {
+ // A Debug build fakes ownership; this test is about the NOT-OWNED state.
+ vm.EntitlementOverride = new FakeEntitlement(false, "Steam licensing is not configured in this build.");
+
+ var row = Assert.Single(vm.AttentionAlerts);
+ Assert.Equal("STEAM LICENCE", row.Label);
+ Assert.True(row.IsPinned);
+ Assert.True(row.ShowsPin);
+ Assert.False(row.CanDismiss);
+ Assert.Equal(AttentionAlertAction.About, row.Action);
+
+ // A status readout: what this build is entitled to, and the maximum.
+ Assert.Contains("Entitlement 30 fps", row.Text);
+ Assert.Contains("Maximum 60 fps", row.Text);
+ Assert.DoesNotContain("unavailable", row.Text, StringComparison.OrdinalIgnoreCase);
+ });
+ }
+
+ [Fact]
+ public void OwnedLicence_MakesTheIndicatorClosable_AndTheCloseIsPermanent()
+ {
+ WithVm((vm, db) =>
+ {
+ vm.EntitlementOverride = new FakeEntitlement(true);
+
+ var row = Assert.Single(vm.AttentionAlerts);
+ Assert.False(row.IsPinned);
+ Assert.False(row.ShowsPin);
+ Assert.True(row.CanDismiss);
+ Assert.Contains("Entitlement 60 fps", row.Text);
+
+ vm.DismissAlertCommand.Execute(row);
+ Assert.Empty(vm.AttentionAlerts);
+
+ // Permanent: a fresh view-model on the same profile must not bring it back…
+ var reloaded = new MainViewModel();
+ Assert.DoesNotContain(reloaded.AttentionAlerts, alert => alert.Label == "STEAM LICENCE");
+
+ // …but losing ownership restores it as a pinned indicator: a change of state, not a preference.
+ reloaded.EntitlementOverride = new FakeEntitlement(false, "Steam licensing is not configured in this build.");
+ var restored = reloaded.AttentionAlerts.First(alert => alert.Label == "STEAM LICENCE");
+ Assert.True(restored.IsPinned);
+ });
+ }
+
+ [Fact]
+ public void PinnedIndicator_CannotBeDismissed()
+ {
+ WithVm(vm =>
+ {
+ vm.EntitlementOverride = new FakeEntitlement(false, "Steam licensing is not configured in this build.");
+ var row = vm.AttentionAlerts.Single();
+ vm.DismissAlertCommand.Execute(row);
+ Assert.Single(vm.AttentionAlerts); // still there: it is state, not an alert to clear
+ });
+ }
+
+ [Fact]
+ public void EventRows_StackBeneathTheIndicator_NewestFirst_AndDismissIndividually()
+ {
+ WithVm(vm =>
+ {
+ vm.PushAttentionAlertForTest("drop-1", "STREAM", "Stream dropped — reconnecting (attempt 1 of 4)", 10, 12);
+ vm.PushAttentionAlertForTest("drop-2", "STREAM", "Stream dropped — reconnecting (attempt 2 of 4)", 13, 15);
+
+ Assert.Equal(new[] { "STEAM LICENCE", "STREAM", "STREAM" }, vm.AttentionAlerts.Select(a => a.Label));
+ // Newest event sits directly under the indicator.
+ Assert.Contains("attempt 2", vm.AttentionAlerts[1].Text);
+ Assert.Contains("attempt 1", vm.AttentionAlerts[2].Text);
+
+ // The same event advancing updates its row instead of adding another.
+ vm.PushAttentionAlertForTest("drop-2", "STREAM", "Stream recovered after 1.4 s", 13, 16);
+ Assert.Equal(3, vm.AttentionAlerts.Count);
+ Assert.Contains("recovered", vm.AttentionAlerts[1].Text);
+
+ vm.DismissAlertCommand.Execute(vm.AttentionAlerts[1]);
+ Assert.Equal(2, vm.AttentionAlerts.Count);
+ Assert.Equal("STEAM LICENCE", vm.AttentionAlerts[0].Label);
+ });
+ }
+
+ [Fact]
+ public void ClickingAnEventRow_OpensTheLogsTab_AndHighlightsItsRange()
+ {
+ WithVm(vm =>
+ {
+ vm.PushAttentionAlertForTest("drop-1", "STREAM", "Stream dropped", 41, 47);
+
+ vm.ActivateAlertCommand.Execute(vm.AttentionAlerts[1]);
+
+ Assert.True(vm.IsLogsTabSelected);
+ Assert.Equal(41, vm.HighlightedLogFrom);
+ Assert.Equal(47, vm.HighlightedLogTo);
+ });
+ }
+
+ private void WithVm(Action body) => WithVm((vm, _) => body(vm));
+
+ private void WithVm(Action body)
+ {
+ _app.Run(() =>
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-alerts-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ var window = new MainWindow();
+ var vm = (MainViewModel)window.DataContext;
+ try
+ {
+ body(vm, tempDb);
+ }
+ finally
+ {
+ window.Close();
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
+ }
+ });
+ }
+}
diff --git a/ytLive.Tests/EntitlementTests.cs b/ytLive.Tests/EntitlementTests.cs
index 883e02a..78b3467 100644
--- a/ytLive.Tests/EntitlementTests.cs
+++ b/ytLive.Tests/EntitlementTests.cs
@@ -22,7 +22,9 @@ public class EntitlementTests
{
var entitlement = SteamEntitlement.NotConfigured();
Assert.False(entitlement.IsAppOwned);
- Assert.Contains("paid Steam app ID is not configured", entitlement.ErrorMessage);
+ // The diagnostic is deliberately neutral (2026-11-05): the licence state is shown to the
+ // creator as a status indicator, so the log text carries no "contact support" wording.
+ Assert.Contains("Steam licensing is not configured", entitlement.ErrorMessage);
}
[Fact]