diff --git a/Controls/OverlayHost.xaml b/Controls/OverlayHost.xaml
index 012e1cf..de535fd 100644
--- a/Controls/OverlayHost.xaml
+++ b/Controls/OverlayHost.xaml
@@ -112,6 +112,17 @@
Value="{Binding TransitionDurationMs, Mode=TwoWay}" VerticalAlignment="Center"/>
+
+
+
+
+
+
+
diff --git a/MainWindow.xaml b/MainWindow.xaml
index c179103..4dea0e7 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -19,6 +19,7 @@
Loaded="MainWindow_Loaded"
Activated="MainWindow_Activated"
Deactivated="MainWindow_Deactivated"
+ PreviewKeyDown="MainWindow_PreviewKeyDown"
PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown"
ContextMenuOpening="Window_ContextMenuOpening">
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 0725640..140a408 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -162,6 +162,22 @@ public partial class MainWindow : Window
{
}
+ ///
+ /// Ctrl released/late-pressed while the magnifier is open → the box closes (creator ruling
+ /// 2026-11-01). This is the "let go and it's gone" half of the zoom gesture; it also means one
+ /// hand can dismiss without reaching for the mouse. Creating the box still takes Ctrl+right
+ /// (edit) or a left click (preview), so holding Ctrl to *make* it never cancels it: the key is
+ /// already down before the box exists.
+ ///
+ private void MainWindow_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
+ {
+ if (e.Key is System.Windows.Input.Key.LeftCtrl or System.Windows.Input.Key.RightCtrl
+ && _viewModel.IsZoomActive)
+ {
+ _viewModel.DismissZoomIfActive();
+ }
+ }
+
private void UpdateTaskbarOverlay()
{
TaskbarInfo.Overlay = _viewModel.IsLive
diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs
index de6a4b7..a1d302d 100644
--- a/Services/LayoutStore.Settings.cs
+++ b/Services/LayoutStore.Settings.cs
@@ -45,6 +45,13 @@ public partial class LayoutStore : IDisposable
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
}
+ /// Zoom box follow mode (creator ruling 2026-11-01): false = ANCHOR (the capture
+ /// stays on the point that was clicked, the default), true = FOLLOW (the capture re-anchors
+ /// to the box centre while it is dragged).
+ public bool LoadZoomFollowBox() => GetSetting("Zoom.FollowBox") == "1";
+
+ public void SaveZoomFollowBox(bool follow) => UpsertSetting("Zoom.FollowBox", follow ? "1" : "0");
+
/// Mono output (creator ruling 2026-10-30): folds the mix so single-sided audio is
/// audible on both sides. Restored at startup — an accessibility switch that resets is a
/// failure, not a default.
diff --git a/ViewModels/MainViewModel.Zoom.cs b/ViewModels/MainViewModel.Zoom.cs
index c8c5310..0d8923c 100644
--- a/ViewModels/MainViewModel.Zoom.cs
+++ b/ViewModels/MainViewModel.Zoom.cs
@@ -79,6 +79,41 @@ public partial class MainViewModel
/// only otherwise) made the gesture do nothing at all when idle — the box could be turned off
/// but never on (creator, 2026-11-01).
///
+ ///
+ /// Settings pill: **Anchor** (default, off) keeps the capture on the clicked point while the
+ /// box moves; **Follow** (on) re-anchors the capture to the box centre so the magnifier shows
+ /// whatever it hovers over. Persisted, because a creator who chose follow should not have to
+ /// choose it again every launch.
+ ///
+ private bool _zoomFollowsBox;
+ public bool ZoomFollowsBox
+ {
+ get => _zoomFollowsBox;
+ set
+ {
+ if (!SetProperty(ref _zoomFollowsBox, value)) return;
+ _layoutStore.SaveZoomFollowBox(value);
+ // Switching to follow mid-hold snaps the capture under the box immediately rather
+ // than waiting for the next drag.
+ if (value && _zoomState.IsActive)
+ {
+ ReanchorCaptureToBox();
+ var zoomTarget = StagedScene?.Elements.OfType().FirstOrDefault(e => e.IsZoomActive);
+ if (zoomTarget != null)
+ {
+ zoomTarget.CaptureX = _zoomState.CaptureX;
+ zoomTarget.CaptureY = _zoomState.CaptureY;
+ }
+ if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
+ OnPropertyChanged(nameof(ZoomState));
+ }
+ }
+ }
+
+ /// Releasing the box: called when Ctrl goes down while the magnifier is open, so the
+ /// creator can dismiss it with the same hand that opened it (in addition to the gesture).
+ public void DismissZoomIfActive() => DismissZoom();
+
public void ToggleZoom(double clickX, double clickY)
{
if (_zoomState.IsActive) DismissZoom();
@@ -123,13 +158,32 @@ public partial class MainViewModel
{
zoomTarget.X = _zoomState.X;
zoomTarget.Y = _zoomState.Y;
- // CaptureX/Y are deliberately NOT touched: dragging relocates the box,
- // never the anchored captured area (creator ruling 2026-10-08).
+ // ANCHOR (default): CaptureX/Y are deliberately untouched — dragging relocates the
+ // box over the area that was clicked (creator ruling 2026-10-08). FOLLOW (opt-in via
+ // the Settings pill): the capture re-anchors to the box centre every move, so the
+ // magnifier shows whatever it is hovering over (creator ruling 2026-11-01).
+ if (_zoomFollowsBox)
+ {
+ ReanchorCaptureToBox();
+ zoomTarget.CaptureX = _zoomState.CaptureX;
+ zoomTarget.CaptureY = _zoomState.CaptureY;
+ }
}
OnPropertyChanged(nameof(ZoomState));
RebuildDimRectsForZoom();
}
+ /// Point the capture window at the box's current centre. The capture is half the
+ /// box's size, exactly as computes it, and clamped to the master
+ /// frame independently so an edge drag can keep the box on screen and the capture legal.
+ private void ReanchorCaptureToBox()
+ {
+ var boxCentreX = _zoomState.X + _zoomState.Width / 2;
+ var boxCentreY = _zoomState.Y + _zoomState.Height / 2;
+ _zoomState.CaptureX = Math.Clamp(boxCentreX - _zoomState.Width / 4, 0, MasterFrameWidth - _zoomState.Width / 2);
+ _zoomState.CaptureY = Math.Clamp(boxCentreY - _zoomState.Height / 4, 0, MasterFrameHeight - _zoomState.Height / 2);
+ }
+
public void EndZoomMove()
{
if (_zoomState.IsMoving)
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 01dcdf7..b724273 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -277,6 +277,7 @@ public partial class MainViewModel : ViewModelBase
// (yellow) instead of silently falling back to the default.
MicSourceName = _layoutStore.LoadMicSourceName();
AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
+ _zoomFollowsBox = _layoutStore.LoadZoomFollowBox();
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 2bbb7f2..ead2eb2 100644
--- a/docs/ai/ui-surface.md
+++ b/docs/ai/ui-surface.md
@@ -104,7 +104,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB
- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = YouTube chat. 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.
- **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. **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.
+- **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.
- **Preview undo/redo (2026-11-01):** `Ctrl+Z` / `Ctrl+Shift+Z`, window-level bindings, live only while `EditingActive` — undo with the preview showing program output would rewrite a scene nobody is looking at. `Models/PreviewHistory` snapshots element **references** plus geometry, visibility, order and membership, so move / resize / reorder / add / remove / hide all undo in one step per gesture (`PushPreviewUndo` is called once at drag or resize start, never inside the move loop). Property edits (colour, opacity, border, font) are **not** captured yet — stated in the type's doc comment so nobody assumes otherwise. The stack clears on layout load. Guard: `ytLive.Tests/PreviewHistoryTests.cs`.
- **Developer affordances are compiled out of release, not hidden.** Any control that must never ship (today: the all-resolutions override) lives in its own `UserControl` whose `Page` and `Compile` items are removed by `ytLive.csproj` when `$(Configuration) != 'Debug'`, and is added to its host at runtime inside `#if DEBUG` (`OverlayHost`). A collapsed row still exists in the visual tree of every shipped copy; "not compiled" is the only claim worth making. Label such rows with `Brush.Attention` (orange) while they are visible in Debug. Guard: `ytLive.Tests/OutputProfileTests.cs` asserts the build rule, and `Helpers/DevBuild.cs` is the single source for "is this a dev build".
- **Scene strip:** five fixed-size cards above the preview show the scene name and hotkey; the staged scene carries a `PROGRAM` badge, and staged/live borders use orange. Click stages a scene while idle; while streaming or recording it changes program output and starts the selected transition.
diff --git a/ytLive.Tests/EntitlementTests.cs b/ytLive.Tests/EntitlementTests.cs
index 753b273..1c3838f 100644
--- a/ytLive.Tests/EntitlementTests.cs
+++ b/ytLive.Tests/EntitlementTests.cs
@@ -14,6 +14,9 @@ namespace ytLive.Tests;
/// DLC id, a missing Steam client or a thrown native load all resolve to false; none of them may
/// crash the app or quietly grant Pro.
///
+// Shares the static MainViewModel.LayoutPathOverride with the sibling class; without a
+// shared collection xUnit runs them in parallel and the two view models fight over one temp DB.
+[Collection("MainViewModelLayoutPath")]
public class EntitlementTests
{
[Fact]
diff --git a/ytLive.Tests/ZoomFollowModeTests.cs b/ytLive.Tests/ZoomFollowModeTests.cs
new file mode 100644
index 0000000..f892dd6
--- /dev/null
+++ b/ytLive.Tests/ZoomFollowModeTests.cs
@@ -0,0 +1,105 @@
+using System;
+using System.IO;
+using Xunit;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// Zoom box follow mode (creator ruling 2026-11-01): the Settings pill toggles between **Anchor**
+/// — the capture stays on the clicked point, which is the default and the 2026-10-08 model — and
+/// **Follow**, where dragging the box re-anchors the capture to the box centre so the magnifier
+/// shows whatever it hovers over.
+///
+// Shares the static MainViewModel.LayoutPathOverride with the sibling class; without a
+// shared collection xUnit runs them in parallel and the two view models fight over one temp DB.
+[Collection("MainViewModelLayoutPath")]
+public class ZoomFollowModeTests
+{
+ private static MainViewModel NewVm(out string dbPath)
+ {
+ dbPath = Path.Combine(Path.GetTempPath(), $"llamacasty-zoom-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = dbPath;
+ return new MainViewModel();
+ }
+
+ [Fact]
+ public void Anchor_is_the_default_and_dragging_leaves_the_capture_alone()
+ {
+ var vm = NewVm(out var dbPath);
+ try
+ {
+ Assert.False(vm.ZoomFollowsBox);
+
+ vm.ActivateZoom(1000, 500);
+ var captureX = vm.ZoomState.CaptureX;
+
+ vm.StartZoomMove(1000, 500);
+ vm.UpdateZoomMove(1200, 700);
+
+ Assert.Equal(captureX, vm.ZoomState.CaptureX);
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ try { File.Delete(dbPath); } catch (IOException) { }
+ }
+ }
+
+ [Fact]
+ public void Follow_moves_the_capture_with_the_box()
+ {
+ var vm = NewVm(out var dbPath);
+ try
+ {
+ vm.ActivateZoom(1000, 500);
+ var anchoredCaptureX = vm.ZoomState.CaptureX;
+
+ vm.ZoomFollowsBox = true;
+ vm.StartZoomMove(1000, 500);
+ vm.UpdateZoomMove(1200, 700);
+
+ Assert.NotEqual(anchoredCaptureX, vm.ZoomState.CaptureX);
+ // The capture is half the box, centred on the box: box centre minus a quarter of it.
+ var expected = Math.Clamp(
+ vm.ZoomState.X + vm.ZoomState.Width / 2 - vm.ZoomState.Width / 4,
+ 0, 1920 - vm.ZoomState.Width / 2);
+ Assert.Equal(expected, vm.ZoomState.CaptureX, 3);
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ try { File.Delete(dbPath); } catch (IOException) { }
+ }
+ }
+
+ [Fact]
+ public void The_choice_persists_in_settings()
+ {
+ // Persistence is the store's job, tested against the store directly: two live view models
+ // in one process fight over the SQLite file and the frame pump, which is a property of the
+ // fixture, not of the setting.
+ var dbPath = Path.Combine(Path.GetTempPath(), $"llamacasty-zoom-{Guid.NewGuid():N}.db");
+ try
+ {
+ using (var store = new ytLive.Services.LayoutStore(dbPath))
+ {
+ Assert.False(store.LoadZoomFollowBox());
+ store.SaveZoomFollowBox(true);
+ }
+
+ using (var reopened = new ytLive.Services.LayoutStore(dbPath))
+ {
+ Assert.True(reopened.LoadZoomFollowBox());
+ reopened.SaveZoomFollowBox(false);
+ }
+
+ using (var again = new ytLive.Services.LayoutStore(dbPath))
+ Assert.False(again.LoadZoomFollowBox());
+ }
+ finally
+ {
+ try { File.Delete(dbPath); } catch (IOException) { }
+ }
+ }
+}