diff --git a/App.xaml.cs b/App.xaml.cs
index 95aa7bd..1dbc5c7 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -19,10 +19,11 @@ public partial class App : Application
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
AppLog.Write(args.ExceptionObject is Exception ex
? "AppDomain unhandled exception: " + ex
- : "AppDomain unhandled exception object: " + args.ExceptionObject);
+ : "AppDomain unhandled exception object: " + args.ExceptionObject,
+ AppLogMessageType.Error, AppLogErrorClass.Class1);
DispatcherUnhandledException += (_, args) =>
- AppLog.Write(args.Exception, "Dispatcher unhandled exception");
+ AppLog.Write(args.Exception, "Dispatcher unhandled exception", AppLogErrorClass.Class1);
AppLog.Write("App OnStartup begin");
// Attribution (2026-09-04): every take must be traceable to the exact binary
diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index e1b35a0..01e73a7 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -989,20 +989,56 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Controls/index.md b/Controls/index.md
index 610e378..d6f7320 100644
--- a/Controls/index.md
+++ b/Controls/index.md
@@ -20,8 +20,9 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
The Add Layer menu exposes one standalone recent-alert Ticker per scene. The
properties panel edits its independent `Ticker` settings; Stream Alerts retains
its separate current-event ticker controls. Outside edit mode, the left panel
-offers Live Chat first and Logs second; Logs shows this process's newest AppLog
-entries first and excludes earlier file contents.
+offers Live Chat first and Logs second; Logs wraps and colors typed entries,
+pins explicit Class 1/2 errors above the timeline until acknowledged, then
+returns them to the timeline; it excludes earlier file contents.
## Window ↔ control contract
diff --git a/HANDOFF.md b/HANDOFF.md
index 50efc69..057dbca 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,24 +1,24 @@
-# HANDOFF — 2026-11-02 (Logs tab + test sweep)
+# HANDOFF — 2026-11-02 (Logs severity and acknowledgment)
## State
-- `main`; all work is committed locally, not pushed.
-- Non-edit left panel has Live Chat and Logs tabs. Logs shows the newest 500 AppLog entries from
- this process only; file format and rotation are unchanged. Feature commit: `9fa06c6`.
-- The 13 failures from the prior full-suite run were resolved in separate focused commits; see
- `git log` for each test-failure task.
-- `./scripts/verify.sh` passes: clean app build, 0 warnings, 522/522 tests, scope check.
+- `main`; previous Logs tab and 13 test-failure fixes are committed locally, not pushed.
+- Logs wraps and colors by explicit message type. Class 1 (fatal/unhandled) and Class 2 (recoverable,
+ actionable) are independent metadata; Class 1 pins above Class 2. Acknowledging unpins but retains
+ the entry in the current-launch timeline. Disk format and rotation are unchanged.
+- Logs integration test passes; clean app build is 0 warnings and scope-check passes. The last full
+ run was 521/522: only the real-mouse layer-reorder test failed while the creator was using the
+ desktop. It was not rerun; the focused drag test passes in isolation.
## Next action
-Day 1's separate system-message strip was replaced by the Logs tab per the creator's ruling. Next
-release check: Debug-only `InternalsVisibleTo`. Keep one integration test per change; do not push
-absent a milestone/checkpoint request.
+Commit this Logs enhancement with the interactive-mouse test exception recorded. Do not rerun that
+test while the creator is playing; do not push absent a checkpoint request.
## Landmines
-- Build/test with Windows `dotnet.exe`; full suite can hang in WASAPI teardown.
-- Any integration test constructing `MainWindow` must use a temporary `LayoutPathOverride` and
+- `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` injects physical
+ mouse input. It passed in isolation; do not run it while the desktop is in use.
+- Any integration test constructing `MainWindow` must set a temporary `LayoutPathOverride` and
clear it in `finally`.
-- Debug-only `InternalsVisibleTo` remains an outstanding release check.
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
diff --git a/Helpers/AppLog.cs b/Helpers/AppLog.cs
index c60bd31..65b13c7 100644
--- a/Helpers/AppLog.cs
+++ b/Helpers/AppLog.cs
@@ -2,6 +2,24 @@ using System.IO;
namespace ytLive.Helpers;
+/// Presentation category; independent from the entry's attention class.
+public enum AppLogMessageType
+{
+ Information,
+ Success,
+ Warning,
+ Error,
+}
+
+/// Explicit attention priority, assigned by the writer rather than inferred from text or source.
+public enum AppLogErrorClass
+{
+ /// Fatal or unhandled failure; highest priority.
+ Class1 = 1,
+ /// Recoverable failure that still requires attention.
+ Class2 = 2,
+}
+
///
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
/// where the WPF app has no visible console. Writes to
@@ -39,6 +57,15 @@ public static class AppLog
}
public static void Write(string message)
+ => Write(message, AppLogMessageType.Information);
+
+ public static void WriteWarning(string message)
+ => Write(message, AppLogMessageType.Warning);
+
+ public static void WriteSuccess(string message)
+ => Write(message, AppLogMessageType.Success);
+
+ public static void Write(string message, AppLogMessageType type, AppLogErrorClass? errorClass = null)
{
try
{
@@ -46,7 +73,9 @@ public static class AppLog
{
var entry = new AppLogEntry(
++_nextSequence,
- $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}");
+ $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}",
+ type,
+ errorClass);
CurrentLaunchEntries.Add(entry);
if (CurrentLaunchEntries.Count > CurrentLaunchEntryLimit)
CurrentLaunchEntries.RemoveAt(0);
@@ -65,8 +94,29 @@ public static class AppLog
public static void Write(Exception exception, string message)
{
- Write($"{message}: {exception}");
+ Write($"{message}: {exception}", AppLogMessageType.Error);
}
+
+ public static void Write(Exception exception, string message, AppLogErrorClass errorClass)
+ => Write($"{message}: {exception}", AppLogMessageType.Error, errorClass);
}
-public sealed record AppLogEntry(long Sequence, string Text);
+public sealed record AppLogEntry(long Sequence, string Text, AppLogMessageType Type,
+ AppLogErrorClass? ErrorClass)
+{
+ public string? ErrorClassLabel => ErrorClass switch
+ {
+ AppLogErrorClass.Class1 => "CLASS 1",
+ AppLogErrorClass.Class2 => "CLASS 2",
+ _ => null,
+ };
+
+ public string TypeLabel => ErrorClassLabel ?? Type switch
+ {
+ AppLogMessageType.Information => "INFO",
+ AppLogMessageType.Success => "SUCCESS",
+ AppLogMessageType.Warning => "WARNING",
+ AppLogMessageType.Error => "ERROR",
+ _ => Type.ToString().ToUpperInvariant(),
+ };
+}
diff --git a/Helpers/index.md b/Helpers/index.md
index 5fef211..f19a1e1 100644
--- a/Helpers/index.md
+++ b/Helpers/index.md
@@ -6,7 +6,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|------|---------|
| `ViewModelBase.cs` | `INotifyPropertyChanged` + `SetProperty()` — base for all ViewModels |
| `RelayCommand.cs` | `ICommand` implementation for all button actions |
-| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture. Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55). Also keeps the newest 500 formatted entries in a process-local buffer for the Logs tab; it never loads prior-launch file contents |
+| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture. Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55). Also keeps the newest 500 formatted entries in a process-local buffer for the Logs tab, with independent message type and explicit Class 1/2 severity metadata; it never loads prior-launch file contents |
| `LogRotation.cs` | The size-capped-rotation policy as a pure function over a path: roll `startup.log` → `startup.1.log` when it passes the cap, keeping N rolled copies. Separate from `AppLog` so the logger's 156-call-site surface never changes and the rule is testable alone (TASK 55) |
| `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard |
| `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) |
diff --git a/Services/AlertClipDecoder.cs b/Services/AlertClipDecoder.cs
index 3cf837c..46594d9 100644
--- a/Services/AlertClipDecoder.cs
+++ b/Services/AlertClipDecoder.cs
@@ -123,7 +123,7 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
// animations (or the frame it already holds) exactly as a missing file.
// Logged so a silent blank box traces back to a decode failure.
failed = true;
- AppLog.Write(ex, $"Alert clip decode failed ({Path.GetFileName(_path)})");
+ AppLog.Write(ex, $"Alert clip decode failed ({Path.GetFileName(_path)})", AppLogErrorClass.Class2);
}
if (!ct.IsCancellationRequested)
@@ -224,4 +224,4 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
}
return chunks;
}
-}
\ No newline at end of file
+}
diff --git a/Services/AlertOverlayLayer.cs b/Services/AlertOverlayLayer.cs
index 916a594..117c861 100644
--- a/Services/AlertOverlayLayer.cs
+++ b/Services/AlertOverlayLayer.cs
@@ -370,7 +370,7 @@ public sealed class AlertOverlayLayer : IDisposable
}
catch (Exception ex)
{
- AppLog.Write(ex, "Alert sound byte failed");
+ AppLog.Write(ex, "Alert sound byte failed", AppLogErrorClass.Class2);
StopSoundByte();
}
}
@@ -413,7 +413,7 @@ public sealed class AlertOverlayLayer : IDisposable
}
catch (Exception ex)
{
- AppLog.Write(ex, "Alert clip setup failed");
+ AppLog.Write(ex, "Alert clip setup failed", AppLogErrorClass.Class2);
StopClip(); // decode setup failed (bad path) — fall back to animations
}
}
diff --git a/Services/Audio/AlertAudioOutput.cs b/Services/Audio/AlertAudioOutput.cs
index 67aed35..098dd69 100644
--- a/Services/Audio/AlertAudioOutput.cs
+++ b/Services/Audio/AlertAudioOutput.cs
@@ -42,7 +42,7 @@ public sealed class AlertAudioOutput : IDisposable
{
// No output device (a headless stream box, a locked-down session): the alert still
// renders, it is simply silent. Never let audio take the alert down.
- AppLog.Write(ex, "Alert audio output failed");
+ AppLog.Write(ex, "Alert audio output failed", AppLogErrorClass.Class2);
Stop();
}
}
diff --git a/Services/Audio/AudioMixer.cs b/Services/Audio/AudioMixer.cs
index 51899e5..130bd94 100644
--- a/Services/Audio/AudioMixer.cs
+++ b/Services/Audio/AudioMixer.cs
@@ -422,7 +422,7 @@ public sealed class AudioMixer : IDisposable
if (now - _lastLoopErrorLogged >= TimeSpan.FromSeconds(5))
{
_lastLoopErrorLogged = now;
- AppLog.Write(ex, "Audio live loop error");
+ AppLog.Write(ex, "Audio live loop error", AppLogErrorClass.Class2);
_log?.Invoke($"Audio live loop error: {ex.Message}");
}
}
diff --git a/Services/CameraManager.cs b/Services/CameraManager.cs
index 415e152..bad4c9e 100644
--- a/Services/CameraManager.cs
+++ b/Services/CameraManager.cs
@@ -317,7 +317,8 @@ public sealed class CameraManager : IDisposable
if (suspects.Count > 0)
enriched += $" Other camera apps running: {string.Join(", ", suspects)}.";
- AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}");
+ AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
CameraFailed?.Invoke(session.DeviceId, enriched);
_ = SafeStopAsync(session.Source);
}
@@ -335,7 +336,8 @@ public sealed class CameraManager : IDisposable
}
catch (Exception ex)
{
- AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}");
+ AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
@@ -460,7 +462,8 @@ public sealed class CameraManager : IDisposable
catch (Exception ex)
{
lock (_gate) session.EffectWorkerRunning = false;
- AppLog.Write($"CameraManager: background effects worker failed: {ex.GetType().Name}: {ex.Message}");
+ AppLog.Write($"CameraManager: background effects worker failed: {ex.GetType().Name}: {ex.Message}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
diff --git a/Services/ChannelStatsService.cs b/Services/ChannelStatsService.cs
index f06babb..3739cd9 100644
--- a/Services/ChannelStatsService.cs
+++ b/Services/ChannelStatsService.cs
@@ -1,6 +1,8 @@
using System.Net.Http;
using System.Text.Json;
using ytLive.Models;
+using AppLogErrorClass = ytLive.Helpers.AppLogErrorClass;
+using AppLogMessageType = ytLive.Helpers.AppLogMessageType;
using AppLog = ytLive.Helpers.AppLog;
namespace ytLive.Services;
@@ -54,7 +56,8 @@ public class ChannelStatsService
// (insufficientPermissions / quotaExceeded / …) — the bare code was
// undiagnosable for the 2026-09-23 auditDetails 403.
var errorBody = await response.Content.ReadAsStringAsync();
- AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode}): {errorBody}");
+ AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode}): {errorBody}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return null;
}
@@ -107,7 +110,8 @@ public class ChannelStatsService
$"{ApiBase}/playlistItems?part=snippet&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults=50");
if (!response.IsSuccessStatusCode)
{
- AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})");
+ AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return 0;
}
@@ -129,4 +133,4 @@ public class ChannelStatsService
}
return count;
}
-}
\ No newline at end of file
+}
diff --git a/Services/Encoder/FfmpegEncoder.cs b/Services/Encoder/FfmpegEncoder.cs
index 896d5a9..a5447ed 100644
--- a/Services/Encoder/FfmpegEncoder.cs
+++ b/Services/Encoder/FfmpegEncoder.cs
@@ -103,7 +103,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
- AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess");
+ AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess", AppLogErrorClass.Class2);
lock (_gate) _options = null;
throw;
}
@@ -175,7 +175,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
- AppLog.Write(ex, "FFmpeg encoder: drain loop faulted during stop");
+ AppLog.Write(ex, "FFmpeg encoder: drain loop faulted during stop", AppLogErrorClass.Class2);
}
try
@@ -186,12 +186,13 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (OperationCanceledException)
{
- AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing");
+ AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
process!.Kill();
}
catch (Exception ex)
{
- AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed");
+ AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed", AppLogErrorClass.Class2);
process!.Kill();
}
@@ -201,7 +202,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
- AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop");
+ AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop", AppLogErrorClass.Class2);
}
process.Dispose();
@@ -263,7 +264,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
- AppLog.Write(ex, "FFmpeg encoder: drain loop write faulted");
+ AppLog.Write(ex, "FFmpeg encoder: drain loop write faulted", AppLogErrorClass.Class2);
}
finally
{
@@ -274,7 +275,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
- AppLog.Write(ex, "FFmpeg encoder: drain loop faulted");
+ AppLog.Write(ex, "FFmpeg encoder: drain loop faulted", AppLogErrorClass.Class2);
}
});
}
@@ -303,7 +304,8 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
}
catch (Exception ex)
{
- AppLog.Write(ex, "FFmpeg encoder: encoder probe failed — falling back to software");
+ AppLog.Write($"FFmpeg encoder: encoder probe failed — falling back to software: {ex}",
+ AppLogMessageType.Warning);
return FfmpegEncoderPicker.Preference[^1];
}
}
@@ -347,14 +349,15 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
// The old `code != 0` gate let ffmpeg's clean early exit vanish
// without a word while the pump kept feeding a corpse
// (2026-09-01 first real recording: died at ~0.9s, zero logs).
- AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly (code {exitCode})");
+ AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly (code {exitCode})",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
_health.LastError = $"FFmpeg exited with code {exitCode}";
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {exitCode}");
}
}
catch (Exception ex)
{
- AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted");
+ AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted", AppLogErrorClass.Class2);
}
});
}
diff --git a/Services/ScreenCaptureManager.cs b/Services/ScreenCaptureManager.cs
index eaf73a4..c876884 100644
--- a/Services/ScreenCaptureManager.cs
+++ b/Services/ScreenCaptureManager.cs
@@ -79,7 +79,8 @@ public sealed class ScreenCaptureManager : IDisposable
}
catch (Exception ex)
{
- AppLog.Write($"ScreenCaptureManager: creating capture '{key}' failed: {ex.Message}");
+ AppLog.Write($"ScreenCaptureManager: creating capture '{key}' failed: {ex.Message}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
CaptureFailed?.Invoke(key, ex.Message);
return false;
}
@@ -109,7 +110,8 @@ public sealed class ScreenCaptureManager : IDisposable
lock (_gate)
_sessions.Remove(key);
session.Source.FrameAvailable -= session.FrameHandler;
- AppLog.Write($"ScreenCaptureManager: failed to start capture '{key}': {ex.Message}");
+ AppLog.Write($"ScreenCaptureManager: failed to start capture '{key}': {ex.Message}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
CaptureFailed?.Invoke(key, ex.Message);
await SafeStopAsync(session.Source);
return false;
@@ -189,7 +191,8 @@ public sealed class ScreenCaptureManager : IDisposable
}
catch (Exception ex)
{
- AppLog.Write($"ScreenCaptureManager: stopping capture failed: {ex.Message}");
+ AppLog.Write($"ScreenCaptureManager: stopping capture failed: {ex.Message}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
diff --git a/Services/YouTubeStreamService.cs b/Services/YouTubeStreamService.cs
index ff8b965..1fd893d 100644
--- a/Services/YouTubeStreamService.cs
+++ b/Services/YouTubeStreamService.cs
@@ -2,6 +2,8 @@ using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using ytLive.Models;
+using AppLogErrorClass = ytLive.Helpers.AppLogErrorClass;
+using AppLogMessageType = ytLive.Helpers.AppLogMessageType;
using AppLog = ytLive.Helpers.AppLog;
namespace ytLive.Services;
@@ -132,7 +134,8 @@ public class YouTubeStreamService
if (response.IsSuccessStatusCode) return null;
var body = await response.Content.ReadAsStringAsync();
- AppLog.Write($"Broadcast update failed ({(int)response.StatusCode}): {body}");
+ AppLog.Write($"Broadcast update failed ({(int)response.StatusCode}): {body}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return $"YouTube rejected the update ({(int)response.StatusCode})";
}
@@ -170,7 +173,8 @@ public class YouTubeStreamService
if (response.IsSuccessStatusCode) return null;
var body = await response.Content.ReadAsStringAsync();
- AppLog.Write($"Broadcast transition(complete) failed ({(int)response.StatusCode}): {body}");
+ AppLog.Write($"Broadcast transition(complete) failed ({(int)response.StatusCode}): {body}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return $"YouTube rejected the end transition ({(int)response.StatusCode})";
}
@@ -194,7 +198,8 @@ public class YouTubeStreamService
}
catch (Exception ex)
{
- AppLog.Write($"Broadcast privacy read failed: {ex.Message}");
+ AppLog.Write($"Broadcast privacy read failed: {ex.Message}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return null;
}
}
@@ -220,16 +225,19 @@ public class YouTubeStreamService
var broadcast = await _http.DeleteAsync($"{ApiBase}/liveBroadcasts?id={id}");
if (broadcast.IsSuccessStatusCode) return null;
var bBody = await broadcast.Content.ReadAsStringAsync();
- AppLog.Write($"liveBroadcasts.delete failed ({(int)broadcast.StatusCode}): {bBody}");
+ AppLog.Write($"liveBroadcasts.delete failed ({(int)broadcast.StatusCode}): {bBody}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return $"YouTube refused to delete the broadcast ({(int)broadcast.StatusCode})";
}
var body = await video.Content.ReadAsStringAsync();
- AppLog.Write($"videos.delete failed ({(int)video.StatusCode}): {body}");
+ AppLog.Write($"videos.delete failed ({(int)video.StatusCode}): {body}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return $"YouTube refused to delete the video ({(int)video.StatusCode})";
}
catch (Exception ex)
{
- AppLog.Write($"Broadcast delete failed: {ex.Message}");
+ AppLog.Write($"Broadcast delete failed: {ex.Message}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return ex.Message;
}
}
@@ -306,7 +314,8 @@ public class YouTubeStreamService
}
catch (Exception ex)
{
- AppLog.Write($"Broadcast diag failed: {ex.Message}");
+ AppLog.Write($"Broadcast diag failed: {ex.Message}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return null;
}
}
@@ -341,7 +350,8 @@ public class YouTubeStreamService
var response = await _http.GetAsync($"{ApiBase}/{pathAndQuery}");
if (!response.IsSuccessStatusCode)
{
- AppLog.Write($"Broadcast diag GET {pathAndQuery.Split('?')[0]} failed ({(int)response.StatusCode})");
+ AppLog.Write($"Broadcast diag GET {pathAndQuery.Split('?')[0]} failed ({(int)response.StatusCode})",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return default;
}
return JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync());
@@ -401,7 +411,8 @@ public class YouTubeStreamService
var body = await response.Content.ReadAsStringAsync();
var reason = ErrorReason(body);
- AppLog.Write($"Broadcast bind failed ({(int)response.StatusCode}/{reason ?? "unknown"}) for {broadcastId} -> {streamId}: {body}");
+ AppLog.Write($"Broadcast bind failed ({(int)response.StatusCode}/{reason ?? "unknown"}) for {broadcastId} -> {streamId}: {body}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return BindFailureMessage((int)response.StatusCode, reason);
}
@@ -612,7 +623,8 @@ public class YouTubeStreamService
}
var errorBody = await response.Content.ReadAsStringAsync();
- AppLog.Write($"Chat message insert failed ({(int)response.StatusCode}): {errorBody}");
+ AppLog.Write($"Chat message insert failed ({(int)response.StatusCode}): {errorBody}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return $"YouTube rejected the message ({(int)response.StatusCode})";
}
diff --git a/Themes/Controls.xaml b/Themes/Controls.xaml
index 213da58..ca02355 100644
--- a/Themes/Controls.xaml
+++ b/Themes/Controls.xaml
@@ -178,6 +178,30 @@
+
+
diff --git a/Themes/index.md b/Themes/index.md
index 43d6141..6ea10f9 100644
--- a/Themes/index.md
+++ b/Themes/index.md
@@ -5,7 +5,7 @@ memory-map conventions.
| File | Purpose |
|------|---------|
-| `Controls.xaml` | All custom control styles/templates: `YtButton`, `YtButtonSecondary`, `IconButton`, `YtTextBox`, `YtTextBoxCompact` (the dense variant the properties panel uses), `YtComboBox`/`YtComboBoxItem` and `YtQualityComboBoxItem` (paid tiers dim but remain clickable), `YtCheckBox`, scrollbar/slider/tooltip/context-menu/menu-item/separator/list-item (implicit where app-wide), `YtListBoxItem`, `YtLabel`, `SectionHeader` |
+| `Controls.xaml` | All custom control styles/templates: `YtButton`, `YtButtonSecondary`, `IconButton`, `YtTextBox`, `YtTextBoxCompact` (the dense variant the properties panel uses), `YtComboBox`/`YtComboBoxItem` and `YtQualityComboBoxItem` (paid tiers dim but remain clickable), `YtCheckBox`, scrollbar/slider/tooltip/context-menu/menu-item/separator/list-item (implicit where app-wide), `YtListBoxItem`, `YtLabel`, `SectionHeader`, and `AppLogEntryText` (wraps and colors by message type and explicit error class) |
How it's wired:
- Merged once in [`App.xaml`](../App.xaml) — app-wide. New windows get the theme
diff --git a/ViewModels/MainViewModel.Logs.cs b/ViewModels/MainViewModel.Logs.cs
index d91c1d7..3dfa5b9 100644
--- a/ViewModels/MainViewModel.Logs.cs
+++ b/ViewModels/MainViewModel.Logs.cs
@@ -9,11 +9,15 @@ public partial class MainViewModel
{
private const int VisibleLogEntryLimit = 500;
private readonly ObservableCollection _currentLaunchLogEntries = new();
+ private readonly ObservableCollection _pinnedLogEntries = new();
private DispatcherTimer? _currentLaunchLogTimer;
private long _lastCurrentLaunchLogSequence;
private bool _isLogsTabSelected;
public ObservableCollection CurrentLaunchLogEntries => _currentLaunchLogEntries;
+ public ObservableCollection PinnedLogEntries => _pinnedLogEntries;
+ public bool HasPinnedLogEntries => _pinnedLogEntries.Count > 0;
+ public RelayCommand AcknowledgeLogEntryCommand => new(AcknowledgeLogEntry);
public bool IsLiveChatTabSelected
{
@@ -41,12 +45,19 @@ public partial class MainViewModel
private void InitializeCurrentLaunchLogs()
{
- foreach (var entry in AppLog.SnapshotCurrentLaunch()
- .OrderByDescending(entry => entry.Sequence)
- .Take(VisibleLogEntryLimit))
- _currentLaunchLogEntries.Add(entry);
+ var entries = AppLog.SnapshotCurrentLaunch()
+ .OrderByDescending(entry => entry.Sequence)
+ .Take(VisibleLogEntryLimit)
+ .ToArray();
+ foreach (var entry in entries)
+ {
+ if (entry.ErrorClass.HasValue)
+ InsertPinnedLogEntry(entry);
+ else
+ _currentLaunchLogEntries.Add(entry);
+ }
- _lastCurrentLaunchLogSequence = _currentLaunchLogEntries.FirstOrDefault()?.Sequence ?? 0;
+ _lastCurrentLaunchLogSequence = entries.FirstOrDefault()?.Sequence ?? 0;
_currentLaunchLogTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_currentLaunchLogTimer.Tick += (_, _) => RefreshCurrentLaunchLogs();
}
@@ -83,13 +94,51 @@ public partial class MainViewModel
foreach (var entry in newEntries)
{
- _currentLaunchLogEntries.Insert(0, entry);
- if (_currentLaunchLogEntries.Count > VisibleLogEntryLimit)
- _currentLaunchLogEntries.RemoveAt(_currentLaunchLogEntries.Count - 1);
+ if (entry.ErrorClass.HasValue)
+ InsertPinnedLogEntry(entry);
+ else
+ InsertChronologicalLogEntry(entry);
_lastCurrentLaunchLogSequence = entry.Sequence;
}
}
+ private void InsertPinnedLogEntry(AppLogEntry entry)
+ {
+ var index = 0;
+ while (index < _pinnedLogEntries.Count)
+ {
+ var current = _pinnedLogEntries[index];
+ var currentClass = (int)current.ErrorClass!.Value;
+ var entryClass = (int)entry.ErrorClass!.Value;
+ if (currentClass < entryClass
+ || (currentClass == entryClass && current.Sequence > entry.Sequence))
+ index++;
+ else
+ break;
+ }
+
+ _pinnedLogEntries.Insert(index, entry);
+ OnPropertyChanged(nameof(HasPinnedLogEntries));
+ }
+
+ private void AcknowledgeLogEntry(AppLogEntry? entry)
+ {
+ if (entry?.ErrorClass == null || !_pinnedLogEntries.Remove(entry)) return;
+ InsertChronologicalLogEntry(entry);
+ OnPropertyChanged(nameof(HasPinnedLogEntries));
+ }
+
+ private void InsertChronologicalLogEntry(AppLogEntry entry)
+ {
+ var index = 0;
+ while (index < _currentLaunchLogEntries.Count
+ && _currentLaunchLogEntries[index].Sequence > entry.Sequence)
+ index++;
+ _currentLaunchLogEntries.Insert(index, entry);
+ if (_currentLaunchLogEntries.Count > VisibleLogEntryLimit)
+ _currentLaunchLogEntries.RemoveAt(_currentLaunchLogEntries.Count - 1);
+ }
+
private void StopCurrentLaunchLogFeed()
=> _currentLaunchLogTimer?.Stop();
}
diff --git a/ViewModels/MainViewModel.Recording.cs b/ViewModels/MainViewModel.Recording.cs
index e68bd8e..2ade57c 100644
--- a/ViewModels/MainViewModel.Recording.cs
+++ b/ViewModels/MainViewModel.Recording.cs
@@ -29,7 +29,7 @@ public partial class MainViewModel : ViewModelBase
}
catch (Exception ex)
{
- AppLog.Write(ex, "Recording: failed to prepare output path");
+ AppLog.Write(ex, "Recording: failed to prepare output path", AppLogErrorClass.Class2);
return null;
}
}
@@ -45,7 +45,8 @@ public partial class MainViewModel : ViewModelBase
if (!File.Exists(startPath))
{
- AppLog.Write($"Recording: output not found for rename: {startPath}");
+ AppLog.Write($"Recording: output not found for rename: {startPath}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return;
}
_recordLength = _liveElapsed;
@@ -64,7 +65,7 @@ public partial class MainViewModel : ViewModelBase
switch (result.Outcome)
{
case RecordingSaveOutcome.Saved:
- AppLog.Write($"Recording saved: {result.FinalPath}");
+ AppLog.WriteSuccess($"Recording saved: {result.FinalPath}");
_notifications.Info("Recording saved",
Path.GetFileName(result.FinalPath!) +
" — you can rename it in your videos folder at any time.");
@@ -77,7 +78,8 @@ public partial class MainViewModel : ViewModelBase
case RecordingSaveOutcome.DiscardFailed:
// A "discarded" recording still sitting on disk is worse than no
// report at all — name the file and the folder so it can be found.
- AppLog.Write($"Recording discard failed: {startPath}");
+ AppLog.Write($"Recording discard failed: {startPath}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Recording discarded?",
$"Could not delete {Path.GetFileName(startPath)} — it is still in {dir}.");
break;
@@ -85,7 +87,7 @@ public partial class MainViewModel : ViewModelBase
}
catch (Exception ex)
{
- AppLog.Write(ex, "Recording: rename-on-stop failed");
+ AppLog.Write(ex, "Recording: rename-on-stop failed", AppLogErrorClass.Class2);
_notifications.Error("Recording saved",
$"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}");
}
diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs
index ef7807c..41a0d06 100644
--- a/ViewModels/MainViewModel.Streaming.Operations.cs
+++ b/ViewModels/MainViewModel.Streaming.Operations.cs
@@ -159,7 +159,8 @@ public partial class MainViewModel : ViewModelBase
var stream = await _youtubeStream.GetOrCreateReusableStreamAsync();
if (stream == null)
{
- AppLog.Write("Reusable stream unavailable; check the OAuth session");
+ AppLog.Write("Reusable stream unavailable; check the OAuth session",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Couldn't go live",
"The reusable stream is unavailable — check the YouTube sign-in and try again.");
StopStream(); // full rollback — audio loop + recording + pills, no Error-status zombie
@@ -174,12 +175,13 @@ public partial class MainViewModel : ViewModelBase
_broadcastSeenLive = false;
if (_currentBroadcastId == null)
{
- AppLog.Write("Broadcast creation failed; check the OAuth session");
+ AppLog.Write("Broadcast creation failed; check the OAuth session",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again.");
StopStream(); // full rollback
return;
}
- AppLog.Write($"Broadcast created: {_currentBroadcastId}");
+ AppLog.WriteSuccess($"Broadcast created: {_currentBroadcastId}");
OnPropertyChanged(nameof(CurrentBroadcastId));
// ⛔ THE BINDING — the call whose absence is why nothing ever went live
@@ -198,7 +200,8 @@ public partial class MainViewModel : ViewModelBase
var bindError = await _youtubeStream.BindBroadcastAsync(_currentBroadcastId, stream.Id);
if (bindError != null)
{
- AppLog.Write($"Stream binding failed: {bindError}");
+ AppLog.Write($"Stream binding failed: {bindError}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
// Show the real reason. "Check the sign-in" would be wrong for five of the
// six documented bind failures, and would hide our own ordering bug behind
// a plausible excuse — see BindFailureMessage.
@@ -216,7 +219,8 @@ public partial class MainViewModel : ViewModelBase
}
catch (Exception ex)
{
- AppLog.Write($"Go-live preparation failed: {ex.Message}");
+ AppLog.Write($"Go-live preparation failed: {ex.Message}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Couldn't go live", ex.Message);
StopStream(); // full rollback — a created broadcast (if any) gets the close-out
return;
@@ -242,12 +246,14 @@ public partial class MainViewModel : ViewModelBase
}
else
{
- AppLog.Write("Could not retrieve liveChatId; chat polling disabled");
+ AppLog.Write("Could not retrieve liveChatId; chat polling disabled",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
catch (Exception chatEx)
{
- AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}");
+ AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
}
// TASK 41: a test broadcast hands its session to the Test drawer once
@@ -285,7 +291,8 @@ public partial class MainViewModel : ViewModelBase
var health = await _youtubeStream.GetStreamHealthAsync(_reusableStream.Id);
if (health == null)
{
- AppLog.Write("Stream health poll failed; check the OAuth session");
+ AppLog.Write("Stream health poll failed; check the OAuth session",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
return;
}
CurrentHealth.HealthStatus = health.HealthStatus;
@@ -303,7 +310,8 @@ public partial class MainViewModel : ViewModelBase
}
catch (Exception ex)
{
- AppLog.Write($"Stream health poll failed: {ex.Message}");
+ AppLog.Write($"Stream health poll failed: {ex.Message}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
@@ -375,7 +383,8 @@ public partial class MainViewModel : ViewModelBase
{
var closeOutError = await _youtubeStream.EndBroadcastAsync(endedBroadcastId);
if (closeOutError != null)
- AppLog.Write($"End close-out: {closeOutError} — enableAutoStop will finish the broadcast");
+ AppLog.Write($"End close-out: {closeOutError} — enableAutoStop will finish the broadcast",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
}
_currentBroadcastId = null;
@@ -435,7 +444,7 @@ public partial class MainViewModel : ViewModelBase
// with IsRecording=true and no encoder (first real launch proved it). The
// failure now runs the NORMAL stop path on the UI thread (pills off, audio
// loop closed, recording finalized, close-out if live), then explains itself.
- AppLog.Write($"Frame pump failed: {message}");
+ AppLog.Write($"Frame pump failed: {message}", AppLogMessageType.Error, AppLogErrorClass.Class2);
void Handle()
{
var live = IsLive;
@@ -567,7 +576,8 @@ public partial class MainViewModel : ViewModelBase
_notifications.Info("Broadcast deleted", "The broadcast was removed from YouTube.");
break;
case PrivateBroadcastOutcome.DeleteFailed:
- AppLog.Write($"Private broadcast delete failed: {broadcastId}: {error}");
+ AppLog.Write($"Private broadcast delete failed: {broadcastId}: {error}",
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Couldn't delete the broadcast",
$"{error}. It's still on YouTube as a private video — you can delete it in YouTube Studio.");
break;
diff --git a/ViewModels/index.md b/ViewModels/index.md
index ae6f343..74181c7 100644
--- a/ViewModels/index.md
+++ b/ViewModels/index.md
@@ -21,7 +21,7 @@ areas' docs in their own rows keeps this index small to read.
| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master; **TASK 57:** the tier change also resolves every scene's camera placement (the vertical tier brings the vertical placements in, 16:9 restores the desktop layout — see `MainViewModel.Webcam.cs`). **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. **TASK 57 (Unit 2):** the tier change also raises `CanToggleClipShape` (the circle option disappears in the vertical Chat layout). |
| `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity), `ShowAbout` (returns to the About hub) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` in-app); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` + `PremiumUrl` (`https://llamacasty.com`, opens only when explicitly clicked) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. A locked quality selection opens About without changing the tier. Ctor command assignments stay in core. |
| `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real, then feed **both** component layers), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). **Alerts (TASK 43):** same facade for the native alert box — `_alertLayer` (`AlertOverlayLayer`), `RenderAlertBox`/`UpdateAlertBoxPreview`, `UpdateActiveAlertBoxPreviews`, `CanAddAlerts` (one alert zone per layout). Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `new AlertRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose` + `_alertLayer.Dispose`) stays in core. |
-| `MainViewModel.Logs.cs` | Non-edit left-panel tab state and the bounded current-launch log collection; refreshes the process-local AppLog snapshot while Logs is selected and stops its dispatcher timer on shutdown. |
+| `MainViewModel.Logs.cs` | Non-edit left-panel tab state and bounded current-launch log collections; refreshes the process-local AppLog snapshot while Logs is selected, pins Class 1 before Class 2 until acknowledged, then returns entries to the LIFO timeline; stops its dispatcher timer on shutdown. |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
| **About hub (TASK 11, in `MainViewModel.Overlays.cs`)** | the About overlay is the in-app creator hub: `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public consts, live) + `PremiumUrl` (tabled billing seam — TASK 12 → the greyed "Unlock Premium" button lights up when filled); `OpenLicensesCommand` → `ShowLicenses()` loads the shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` and sets `IsLicensesOpen` (in-app scrolling panel, never the OS viewer); `BackToAbout_Click` in `MainWindow.xaml.cs` flips it off. |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md
index 6092e88..4c616b8 100644
--- a/docs/ai/ui-surface.md
+++ b/docs/ai/ui-surface.md
@@ -103,7 +103,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB
**Current layout (2026-10-26 redesign commits):** `MainWindow` has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer.
-- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = a two-tab panel with **Live Chat** first/default and **Logs** second. Logs is a read-only, newest-first view of up to 500 `AppLog` entries captured in this process; it does not load older `startup.log` contents or change file logging/rotation. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output.
+- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = a two-tab panel with **Live Chat** first/default and **Logs** second. Logs is a read-only view of up to 500 `AppLog` entries captured in this process; it does not load older `startup.log` contents or change file logging/rotation. Entries wrap and use the theme's type colors. Explicit Class 1/2 severity is independent of message type/source: Class 1 sorts above Class 2 in the pinned group; acknowledging unpins the entry but retains it in the chronological list. The staged scene is the preview/edit target; `LiveScene` is the program output.
- **Settings is a right-edge drawer, not a modal (2026-11-01):** the gear rail tab opens `Controls/SettingsDrawer` in the same animated-width stack as Stream Settings / YPP / Socials / TEST (`SettingsDrawerOpen`, exclusive with the other four). Categories are section headings with their controls indented under them — Zoom holds *Zoom view shape* and *Zoom box follows*. The app menu (Report Bug / Feature Request / About) hangs off a **right-click on the LlamaCasty wordmark**; left-click still opens About. The modal shell keeps only Bug / Feature / About, which is why it no longer needs a scroll viewer.
- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its **right edge aligns with the layers panel below** (`TopBar` column 0 is a fixed **352** = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The **settings gear moved to the preview rail** (2026-11-01): first tab, orange (`Brush.Attention`), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings.
- **Preview gestures (2026-11-01):** *edit mode* — left selects/drags/resizes, right opens the context menu, **Ctrl+right toggles the zoom box**. *Preview mode* — **left toggles the zoom box**, right opens the context menu; **Ctrl+right does nothing at all** (no chord while live or recording — one hand, always). ⚠️ **Both the Down and the Up event must be handled for a Ctrl+right-click**: WPF's `PopupControlService` opens a context menu on right-button-**up**, so suppressing only the down event leaves the menu appearing under the gesture (found 2026-11-01 in edit mode). Menus exist on the webcam layer and the backdrop; every other layer right-clicks through to the backdrop menu. Left-dragging with the zoom box open moves it, in both modes. **Ctrl-down closes it**: pressing Ctrl while the box is open dismisses it, so the same hand that opened it can put it away (`MainWindow_PreviewKeyDown`; the box cannot cancel itself on creation because Ctrl is already down before it exists). **Follow mode** is a Settings pill, default **off = Anchor**: with Follow on, dragging re-anchors the capture to the box centre (`ZoomFollowsBox`, persisted as the `Zoom.FollowBox` setting). **The gesture activates the box at the cursor**, not merely dismisses it: `ActivateZoom` no longer refuses when idle (it used to return unless `IsLive || IsRecording`, and `ToggleZoom` only ever dismissed — so the gesture did nothing at all outside a session until 2026-11-01). The social bar consumes its own click (toggle top ⇄ bottom), and the preview's tunnelling handler steps aside for it — otherwise the bar is unclickable in preview mode and drags the layer beneath it in edit mode.
diff --git a/ytLive.Tests/LogsPanelTests.cs b/ytLive.Tests/LogsPanelTests.cs
index 8babbb5..0177312 100644
--- a/ytLive.Tests/LogsPanelTests.cs
+++ b/ytLive.Tests/LogsPanelTests.cs
@@ -1,8 +1,10 @@
using System;
using System.IO;
using System.Linq;
+using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
+using System.Windows.Media;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Helpers;
@@ -38,6 +40,8 @@ public sealed class LogsPanelTests
window.Show();
window.UpdateLayout();
var vm = (MainViewModel)window.DataContext;
+ vm.EditingActive = false;
+ window.UpdateLayout();
var panel = (UserControl)window.FindName("LeftPanel")!;
var chatTab = (ToggleButton)panel.FindName("LiveChatTabButton")!;
var logsTab = (ToggleButton)panel.FindName("LogsTabButton")!;
@@ -46,16 +50,22 @@ public sealed class LogsPanelTests
Assert.True(vm.IsLiveChatTabSelected);
Assert.True(chatTab.IsChecked);
Assert.False(logsTab.IsChecked);
- Assert.Equal(System.Windows.Visibility.Collapsed, logList.Visibility);
+ Assert.False(logList.IsVisible);
vm.IsLogsTabSelected = true;
window.UpdateLayout();
Assert.True(logsTab.IsChecked);
Assert.False(chatTab.IsChecked);
- Assert.Equal(System.Windows.Visibility.Visible, logList.Visibility);
+ Assert.True(logList.IsVisible);
AppLog.Write("LogsPanel older marker");
AppLog.Write("LogsPanel newer marker");
+ AppLog.WriteWarning("LogsPanel warning marker");
+ AppLog.WriteSuccess("LogsPanel success marker");
+ AppLog.Write("LogsPanel class 2 marker " + new string('2', 240),
+ AppLogMessageType.Error, AppLogErrorClass.Class2);
+ AppLog.Write("LogsPanel class 1 marker " + new string('1', 240),
+ AppLogMessageType.Error, AppLogErrorClass.Class1);
vm.RefreshCurrentLaunchLogs();
window.UpdateLayout();
@@ -66,6 +76,47 @@ public sealed class LogsPanelTests
Assert.True(newerIndex < olderIndex, "the Logs tab must put the newest entry first");
Assert.DoesNotContain(entries, text => text.Contains("previous-launch-sentinel", StringComparison.Ordinal));
Assert.Same(vm.CurrentLaunchLogEntries, logList.ItemsSource);
+
+ var class1 = vm.PinnedLogEntries.Single(entry => entry.Text.Contains("LogsPanel class 1 marker", StringComparison.Ordinal));
+ var class2 = vm.PinnedLogEntries.Single(entry => entry.Text.Contains("LogsPanel class 2 marker", StringComparison.Ordinal));
+ Assert.True(Array.IndexOf(vm.PinnedLogEntries.ToArray(), class1)
+ < Array.IndexOf(vm.PinnedLogEntries.ToArray(), class2));
+ Assert.Equal("CLASS 1", class1.ErrorClassLabel);
+ Assert.Equal("CLASS 2", class2.ErrorClassLabel);
+ Assert.Equal("CLASS 1", class1.TypeLabel);
+
+ var pinnedPanel = (ItemsControl)panel.FindName("PinnedLogPanel")!;
+ Assert.Equal(System.Windows.Visibility.Visible, pinnedPanel.Visibility);
+ var class1Text = Find(pinnedPanel, element => element is TextBlock text
+ && text.Text.Contains("LogsPanel class 1 marker", StringComparison.Ordinal)) as TextBlock;
+ var class2Text = Find(pinnedPanel, element => element is TextBlock text
+ && text.Text.Contains("LogsPanel class 2 marker", StringComparison.Ordinal)) as TextBlock;
+ Assert.NotNull(class1Text);
+ Assert.NotNull(class2Text);
+ Assert.Equal(TextWrapping.Wrap, class1Text!.TextWrapping);
+ Assert.Equal(BrushColor("Brush.Error"), Assert.IsType(class1Text.Foreground).Color);
+ Assert.Equal(BrushColor("Brush.Attention"), Assert.IsType(class2Text!.Foreground).Color);
+
+ var warningText = Find(logList, element => element is TextBlock text
+ && text.Text.Contains("LogsPanel warning marker", StringComparison.Ordinal)) as TextBlock;
+ var successText = Find(logList, element => element is TextBlock text
+ && text.Text.Contains("LogsPanel success marker", StringComparison.Ordinal)) as TextBlock;
+ var infoText = Find(logList, element => element is TextBlock text
+ && text.Text.Contains("LogsPanel newer marker", StringComparison.Ordinal)) as TextBlock;
+ Assert.NotNull(warningText);
+ Assert.NotNull(successText);
+ Assert.NotNull(infoText);
+ Assert.Equal(BrushColor("Brush.Secondary"), Assert.IsType(warningText!.Foreground).Color);
+ Assert.Equal(BrushColor("Brush.Positive"), Assert.IsType(successText!.Foreground).Color);
+ Assert.Equal(BrushColor("Brush.OnSurfaceVariant"), Assert.IsType(infoText!.Foreground).Color);
+
+ var class1Ack = Find(pinnedPanel, element => element is Button button
+ && button.DataContext is AppLogEntry entry && entry.Sequence == class1.Sequence) as Button;
+ Assert.NotNull(class1Ack?.Command);
+ Assert.Same(class1, class1Ack!.CommandParameter);
+ class1Ack!.Command!.Execute(class1Ack.CommandParameter);
+ Assert.DoesNotContain(class1, vm.PinnedLogEntries);
+ Assert.Contains(class1, vm.CurrentLaunchLogEntries);
}
finally
{
@@ -81,4 +132,20 @@ public sealed class LogsPanelTests
try { Directory.Delete(directory, recursive: true); } catch { }
}
}
+
+ private static System.Windows.Media.Color BrushColor(string key)
+ => Assert.IsType(Application.Current!.FindResource(key)).Color;
+
+ private static DependencyObject? Find(DependencyObject parent, Func predicate)
+ {
+ if (predicate(parent)) return parent;
+ for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
+ {
+ var child = VisualTreeHelper.GetChild(parent, i);
+ if (predicate(child)) return child;
+ var found = Find(child, predicate);
+ if (found != null) return found;
+ }
+ return null;
+ }
}