diff --git a/Controls/SettingsDrawer.xaml b/Controls/SettingsDrawer.xaml
index 70dd245..f32e8cb 100644
--- a/Controls/SettingsDrawer.xaml
+++ b/Controls/SettingsDrawer.xaml
@@ -143,6 +143,13 @@
+
+
+
+
diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs
index 622239c..25d9a2e 100644
--- a/Services/LayoutStore.Settings.cs
+++ b/Services/LayoutStore.Settings.cs
@@ -20,6 +20,14 @@ public partial class LayoutStore : IDisposable
UpsertSetting("Notifications.Position", position.ToString());
}
+ /// May editing be used while a session runs? Off by default: the creator's ruling
+ /// (2026-11-12) is that moving a layer or resizing a webcam mid-show is theirs to do. Turning it on
+ /// restores the old gun-safety — go-live flips EDIT off and stop flips it back.
+ public bool LoadLockEditWhileLive() => GetSetting("App.LockEditWhileLive") == "1";
+
+ public void SaveLockEditWhileLive(bool locked)
+ => UpsertSetting("App.LockEditWhileLive", locked ? "1" : "0");
+
public bool LoadStickyAlertsUntilDismissed()
=> GetSetting("Notifications.StickyUntilDismissed") == "1";
diff --git a/ViewModels/MainViewModel.Settings.cs b/ViewModels/MainViewModel.Settings.cs
index e6a370c..10065de 100644
--- a/ViewModels/MainViewModel.Settings.cs
+++ b/ViewModels/MainViewModel.Settings.cs
@@ -15,6 +15,8 @@ public partial class MainViewModel
private bool _settingsDrawerOpen;
private NotificationPosition _toastPosition = NotificationPosition.BottomRight;
private bool _stickyAlertsUntilDismissed;
+ private bool _lockEditWhileLive;
+ private bool _editingForcedOff;
public bool StickyAlertsUntilDismissed
{
@@ -26,6 +28,43 @@ public partial class MainViewModel
}
}
+ /// Lock editing for the duration of a session. Off = edit while live (creator ruling
+ /// 2026-11-12); on = go-live flips EDIT off and stop flips it back, as it always did.
+ public bool LockEditWhileLive
+ {
+ get => _lockEditWhileLive;
+ set
+ {
+ if (!SetProperty(ref _lockEditWhileLive, value)) return;
+ _layoutStore.SaveLockEditWhileLive(value);
+ }
+ }
+
+ ///
+ /// Applies the edit lock when a session starts or stops. Split out so the rule is testable without
+ /// starting a real broadcast.
+ ///
+ internal void ApplyEditLockForSession(bool starting)
+ {
+ if (!LockEditWhileLive)
+ {
+ // The creator may edit during the show; nothing is forced either way.
+ _editingForcedOff = false;
+ return;
+ }
+
+ if (starting)
+ {
+ _editingForcedOff = true;
+ EditingActive = false;
+ }
+ else if (_editingForcedOff)
+ {
+ _editingForcedOff = false;
+ EditingActive = true;
+ }
+ }
+
public NotificationPosition ToastPosition
{
get => _toastPosition;
diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs
index 461211a..e823742 100644
--- a/ViewModels/MainViewModel.Streaming.Operations.cs
+++ b/ViewModels/MainViewModel.Streaming.Operations.cs
@@ -298,7 +298,7 @@ public partial class MainViewModel : ViewModelBase
// before that (2026-09-25 diagnosis), so chat resolution runs AFTER the pump
// starts. Chat is non-fatal — if it fails, the stream continues without chat.
LiveScene = StagedScene;
- EditingActive = false;
+ ApplyEditLockForSession(starting: true);
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
// Check promptly until YouTube confirms the broadcast is live. Health
@@ -424,7 +424,7 @@ public partial class MainViewModel : ViewModelBase
_sessionStartedAtUtc = null;
IsRecording = false;
LiveScene = null;
- EditingActive = true;
+ ApplyEditLockForSession(starting: false);
// TASK 41: a stopped test broadcast forgets its drawer session (idempotent)
// and drops the TEST face — a test never outlives its run.
TestSession.EndTestSilently();
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 14b719f..459e26e 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -347,6 +347,7 @@ public partial class MainViewModel : ViewModelBase
_activeLayoutPath = _layoutStore.ActivePath;
_toastPosition = _layoutStore.LoadNotificationPosition();
_stickyAlertsUntilDismissed = _layoutStore.LoadStickyAlertsUntilDismissed();
+ _lockEditWhileLive = _layoutStore.LoadLockEditWhileLive();
_recordFolder = _layoutStore.LoadRecordFolder();
// What the last few sessions on this tier actually held, if it is worth asking about.
diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md
index ad9adbe..125f590 100644
--- a/docs/ai/ui-surface.md
+++ b/docs/ai/ui-surface.md
@@ -145,6 +145,11 @@ Placement math itself is covered by `WindowPlacementTests` (pure function, no HW
`FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
`ytLive.Tests/GeometryFieldWidthTests.cs`
- Resolution tiers: 1080p60@12 (default) → 1080p30@10 → 720p60@6 → 720p30@6 → **Vertical 1080p30@10 and 1080p60@12 (9:16, 1080×1920)** — the `@` is the CBR target the encoder is asked to hold, between YouTube's H.264 recommendation and the practitioner sweet spot (its 14/17/8 recommendations are the max, the tier minimums 5/6/3 the step-down floor). Vertical mirrors its landscape pair: same pixel count, same rate. The old flat 8 Mbps was an inherited round number, not a measurement. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. Under the picker sits the creator's own **rate box** (Mbps, per tier, persisted as `Video.Rate.`): the tier chooses geometry, the box chooses bits, clamped at the tier's YouTube minimum and otherwise unbounded because live step-down is the thing that catches a wrong guess. Its tooltip carries YouTube's recommendation for the tier (`Models/RateLadder`). Every consumer — encoder options, step-down policy, clip buffer — reads `MainViewModel.EffectiveQuality`, never the raw tier. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
+- **Editing during a session is a setting (creator ruling 2026-11-12).** `App.LockEditWhileLive` is off
+ by default: a show does not take EDIT away, so a creator may move a layer or resize a webcam while
+ live. On restores the old gun-safety — go-live flips EDIT off, stop flips it back, and only because
+ the lock took it away (`MainViewModel.ApplyEditLockForSession`, `EditWhileLiveTests`). Settings →
+ Editing carries the checkbox.
- **OPEN (creator, 2026-11-12): can a locked scene still be changed through a layer's visibility eye?**
Reported as "the creator can still use the layer icon — visibility — if it was already enabled for
that layer". Not reproduced from the obvious paths: with EDIT off the left panel swaps the layer list
diff --git a/ytLive.Tests/EditWhileLiveTests.cs b/ytLive.Tests/EditWhileLiveTests.cs
new file mode 100644
index 0000000..f47a892
--- /dev/null
+++ b/ytLive.Tests/EditWhileLiveTests.cs
@@ -0,0 +1,64 @@
+using System;
+using System.IO;
+using Xunit;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// Editing during a session is the creator's call (ruling 2026-11-12: moving a layer or resizing a
+/// webcam mid-show is theirs to do). The setting decides whether go-live still flips EDIT off.
+///
+[Collection("MainViewModelLayoutPath")]
+public class EditWhileLiveTests
+{
+ private static MainViewModel NewVm(string db, out Action cleanup)
+ {
+ MainViewModel.LayoutPathOverride = db;
+ cleanup = () =>
+ {
+ MainViewModel.LayoutPathOverride = null;
+ try { File.Delete(db); } catch (IOException) { }
+ };
+ return new MainViewModel();
+ }
+
+ [Fact]
+ public void Editing_stays_available_during_a_session_unless_the_creator_locked_it()
+ {
+ var db = Path.Combine(Path.GetTempPath(), $"llamacasty-editlock-{Guid.NewGuid():N}.db");
+ var vm = NewVm(db, out var cleanup);
+ try
+ {
+ Assert.False(vm.LockEditWhileLive); // unlocked ships as the default
+ Assert.True(vm.IsEditMode);
+
+ // Unlocked: a session neither takes EDIT away nor gives it back.
+ vm.EditingActive = false;
+ vm.ApplyEditLockForSession(starting: true);
+ Assert.False(vm.EditingActive);
+ vm.EditingActive = true;
+ vm.ApplyEditLockForSession(starting: true);
+ Assert.True(vm.EditingActive);
+
+ // Locked: go-live turns EDIT off…
+ vm.LockEditWhileLive = true;
+ vm.ApplyEditLockForSession(starting: true);
+ Assert.False(vm.EditingActive);
+ Assert.False(vm.IsEditMode);
+
+ // …and stopping restores it, but only because the lock took it away.
+ vm.ApplyEditLockForSession(starting: false);
+ Assert.True(vm.EditingActive);
+ Assert.True(vm.IsEditMode);
+
+ // The choice survives a restart: a second view model on the same profile reads it back.
+ var reloaded = new MainViewModel();
+ Assert.True(reloaded.LockEditWhileLive);
+ }
+ finally
+ {
+ cleanup();
+ }
+ }
+}