diff --git a/HANDOFF.md b/HANDOFF.md
index b983dad..8ee4db8 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -55,31 +55,40 @@ the availability layer. TASK 26's app-wide gate is **superseded** by creator dir
## ✅ Committed — layer-list drag-to-reorder persists (creator-reported, 2026-09-18)
-The drag code in `Controls/LeftPanel.xaml.cs` reordered `StagedScene.Elements` directly
-(`RemoveAt`/`Insert` in `List_PreviewMouseMove`), bypassing `SceneGraph`'s mutation surface — the
-collection changed visually but nothing ever scheduled a save, so the new z-order was lost on
-restart. Fix: the drag sets `_dragReordered` and `EndListDrag` funnels the drop through the new
-`MainViewModel.OnSceneElementsReordered()` (Sources.cs) = `SceneGraph.InvalidateBake(staged) +
-ScheduleSave()`, i.e. the same background-save path every other mutation uses. **Good Dog test**
-`LayerReorderPersistenceTests` (real App + temp DB): reproduces the exact code-behind mutation,
-calls the trap, pumps the dispatcher until the debounced save lands, asserts the DB `Source`
+`StagedScene.Elements` order (RemoveAt/Insert), bypassing SceneGraph's mutation surface, so the
+drop must be trapped and persisted. Fix (c117885): the drag sets `_dragReordered` and, on mouse
+release, `EndListDrag` funnels the drop through `MainViewModel.OnSceneElementsReordered()`
+(Sources.cs) = `SceneGraph.InvalidateBake(staged) + SaveLayoutNow()` — an IMMEDIATE save at drop
+(no debounce window, added 2026-09-18 after the creator re-reported it "doesn't save"; the single
+write stores BOTH the **layer list** = Source `SortOrder` AND the **preview layout** = each layer's
+`X`/`Y`/`Width`/`Height` in the same Source rows; `LayoutStore.Save` deletes+reinserts Source rows
+in `scene.Elements` order, `LayoutStore.Load` reads them back `ORDER BY SortOrder`).
+**Good Dog tests** `LayerReorderPersistenceTests` (real App + temp DB): (1) reproduces the exact
+code-behind mutation
+calls the trap, pumps the dispatcher until the save lands, asserts the DB `Source`
SortOrder matches the in-memory `Elements` order.
**Creator later insisted the reorder "doesn't save" even after the build — settled 2026-09-18 with a
REAL-gesture test.** My first test short-circuited `EndListDrag` — it never exercised the mouse
handlers (impossible with synthetic events: `e.GetPosition` reads the physical cursor). Added
`RealMouseDrag_OnTheLayerList_PersistsTheReorder`: real `SetCursorPos` + `mouse_event`/`SendInput`
against the shown MainWindow physically drag row 3 (ImgC) onto row 1 (ImgA), pumping between steps,
-then asserts the DB lands at `[Background, ImgC, ImgA, ImgB]` via the debounced save. **Both pass.**
+then asserts the DB lands at `[Background, ImgC, ImgA, ImgB]` — AND that ImgC's geometry
+(X/Y/W/H, the preview layout) is intact in the same rows. **Both pass.**
The mouse-down/move/up → `_dragReordered` → `EndListDrag` → `OnSceneElementsReordered` → DB seam is
now proven with real input; a real user drag working differently would mean a stale binary (the
Debug exe from 08:42 postdates fix commit 08:18) or a different deployment.
## ✅ Committed — Rename Recording dialog taller + full-screen hook unhooked on shutdown (2026-09-18)
-- `RenameRecordingDialog.xaml` `Height` 230 → **276** (+20%, creator: textbox cut off). The fix is
- real: content naturally needs ~225px, the old client area at 230 was ~193px (clipped), at 276 it's
- ~239px (fits). **Good Dog test** `RenameRecordingDialogSizingTests`: shows the real dialog in the
- RealApp host and asserts the file-name textbox bottom edge is inside the client area. The dialog's
+- **WHERE/HOW:** the end-of-recording save dialog is `RenameRecordingDialog.xaml` (repo root — the
+ dialog that pops up with a default file name when a recording is ended so it can be saved/renamed;
+ title "Rename Recording"). Its fixed `Height` attribute controls everything: 230 → **276** (+20%,
+ textbox clipped at 230) → **304** (+10% more, creator: the +20% revealed the Cancel/Save button
+ row had ALSO been obscured). The Save button is named `SaveRecordingButton`.
+ **Good Dog test** `RenameRecordingDialogSizingTests`: shows the real dialog in the RealApp host and
+ asserts BOTH the file-name textbox bottom AND the Save button bottom sit inside the client area
+ (client area ≈ Height − ~37px of chrome; measure via `TransformToAncestor` against the dialog's
+ content root). The dialog's
`Icon` was changed from root-relative `/Assets/...` to the explicit `/ytLive;component/...` (the
form `MainWindow.xaml` already uses) — root-relative only resolves in production via
`Application.ResourceAssembly`, so dialogs couldn't be constructed by tests otherwise. The other
diff --git a/MyMistakes.md b/MyMistakes.md
index 048113b..5d9c848 100644
--- a/MyMistakes.md
+++ b/MyMistakes.md
@@ -36,6 +36,28 @@ one rejected call inside a fallback ladder aborts the whole ladder and every unt
Applied in `MediaCaptureFrameSource`'s reader-subtype ladder (2026-09-15, webcam-take fix).
+### "SAVE-RECORDING DIALOG IS CLIPPING CONTENT" — WHERE + HOW (RECIPE)
+
+**WHERE:** the end-of-recording modal (creator: "when I end a recording the app throws up a
+save-recording dialog with a default filename") is **`RenameRecordingDialog.xaml` at the repo ROOT**
+(title "Rename Recording", `x:Class="ytLive.RenameRecordingDialog"`). It is NOT one of the
+`*Dialog.xaml` files under `Controls/` — grep for the window title, not the filename, when the
+user names a dialog by what it does. Only fix dialogs the user actually named; do not "while I'm
+here" bump sibling dialogs.
+
+**HOW:** the dialog is `ResizeMode="NoResize"` with a fixed `Height` attribute; content client area
+≈ `Height − ~37px` of window chrome. Sizing/margin changes pushed content down in Z-order until it
+clipped — first the file-name textbox (230 too short), and after the +20% bump (→276) the creator
+found the Cancel/Save button row had ALSO been obscured. Fixed `Height` to 304 (+10% more),
+`x:Name` the last interactive control (`SaveRecordingButton`) so a test can measure it.
+
+**Verify (never eyeball-predict):** `RenameRecordingDialogSizingTests` (RealApp host) instantiates
+the real dialog, `Show()` + `UpdateLayout()`, then asserts the BOTTOM EDGE of each named control
+(`TransformToAncestor` against the content root → `TransformBounds(RenderSize).Bottom`) is ≤ the
+client `ActualHeight`. Every such dialog fix ships with that assertion for every control that was
+reported obscured. (Client-area math means the button row needs MORE slack than the textbox: its
+bottom sits deeper because of the `Margin="0,20,0,0"` above it.)
+
### SPIN GUARD → RESOLVED — web overlay transparency + bounding box (RECIPE)
**THE ONE ROOT CAUSE THAT EXPLAINS EVERY FAILED TAKE:** WebView2's `CapturePreviewAsync`
diff --git a/RenameRecordingDialog.xaml b/RenameRecordingDialog.xaml
index 763f8b3..a673e6a 100644
--- a/RenameRecordingDialog.xaml
+++ b/RenameRecordingDialog.xaml
@@ -1,7 +1,7 @@
@@ -32,7 +32,7 @@
Margin="0,20,0,0">
-
+
diff --git a/ViewModels/MainViewModel.Sources.cs b/ViewModels/MainViewModel.Sources.cs
index e2dee70..286b73b 100644
--- a/ViewModels/MainViewModel.Sources.cs
+++ b/ViewModels/MainViewModel.Sources.cs
@@ -87,11 +87,14 @@ public partial class MainViewModel
/// Drag-to-reorder in the layer list mutates
/// directly in code-behind, bypassing SceneGraph's mutation surface. Trap it here
- /// so the new z-order persists like any other layout change.
+ /// so the new z-order persists like any other layout change. Saves IMMEDIATELY at
+ /// drop — no debounce window — because a reorder is one discrete event: the write
+ /// stores BOTH the layer list (new SortOrder) and the preview layout (each layer's
+ /// X/Y/W/H) in the same Source rows.
public void OnSceneElementsReordered()
{
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
- ScheduleSave();
+ SaveLayoutNow();
}
// Duplicate resource names get an incrementing suffix with no space: Image,
diff --git a/ytLive.Tests/LayerReorderPersistenceTests.cs b/ytLive.Tests/LayerReorderPersistenceTests.cs
index 31fbe8e..82c552b 100644
--- a/ytLive.Tests/LayerReorderPersistenceTests.cs
+++ b/ytLive.Tests/LayerReorderPersistenceTests.cs
@@ -21,7 +21,8 @@ namespace ytLive.Tests;
/// directly (RemoveAt/Insert), bypassing SceneGraph's mutation surface, so the
/// drop must be trapped as a data change and persisted. Real-App + temp-DB
/// pattern (see WebcamOutputKeyTests); reuses the exact code-behind mutation and
-/// asserts the new order is written to the DB after the debounced background save.
+/// asserts the new order AND each layer's geometry (preview layout) are written
+/// to the DB — immediately at drop, no debounce window.
///
[Collection("RealApp")]
public sealed class LayerReorderPersistenceTests
@@ -97,8 +98,14 @@ public sealed class LayerReorderPersistenceTests
Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected),
TimeSpan.FromSeconds(10)),
- "a real mouse drag-and-drop must be persisted by the debounced background save");
+ "a real mouse drag-and-drop must be persisted by the background save");
Assert.Equal(expected, SourceNamesInDb(tempDb));
+
+ // The same drop must persist the PREVIEW LAYOUT too — each layer's
+ // geometry (X/Y/W/H) rides in the same Source rows as the SortOrder.
+ var dragged = vm.StagedScene.Elements.OfType().Single(s => s.Name == "ImgC");
+ Assert.Equal((dragged.X, dragged.Y, dragged.Width, dragged.Height),
+ GeometryInDb(tempDb, "ImgC"));
}
finally
{
@@ -173,8 +180,15 @@ public sealed class LayerReorderPersistenceTests
Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected),
TimeSpan.FromSeconds(10)),
- "the drag reorder must be persisted by the debounced background save");
+ "the drag reorder must be persisted at drop (immediate save)");
Assert.Equal(expected, SourceNamesInDb(tempDb));
+
+ // The drop must persist the dragged layer's PREVIEW LAYOUT too — its
+ // X/Y/W/H ride in the same Source row, so one immediate save covers
+ // both the new layer list and the preview geometry.
+ var draggedSource = vm.StagedScene.Elements.OfType().Single(s => s.Name == dragged.Name);
+ Assert.Equal((draggedSource.X, draggedSource.Y, draggedSource.Width, draggedSource.Height),
+ GeometryInDb(tempDb, dragged.Name));
}
finally
{
@@ -221,11 +235,31 @@ public sealed class LayerReorderPersistenceTests
}
catch (SqliteException)
{
- // The debounced save may hold the write lock mid-transaction — not yet.
+ // The save may hold the write lock mid-transaction — not yet.
return [];
}
}
+ private static (double X, double Y, double Width, double Height) GeometryInDb(string path, string name)
+ {
+ try
+ {
+ using var connection = new SqliteConnection($"Data Source={path}");
+ connection.Open();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "SELECT X, Y, Width, Height FROM Source WHERE SceneId = 'scene-live' AND Name = $name;";
+ cmd.Parameters.AddWithValue("$name", name);
+ using var reader = cmd.ExecuteReader();
+ reader.Read();
+ return (reader.GetDouble(0), reader.GetDouble(1), reader.GetDouble(2), reader.GetDouble(3));
+ }
+ catch (SqliteException)
+ {
+ // The save may hold the write lock mid-transaction — not yet.
+ return (double.NaN, double.NaN, double.NaN, double.NaN);
+ }
+ }
+
private static void SeedThreeLayers(string path)
{
using var connection = new SqliteConnection($"Data Source={path}");
diff --git a/ytLive.Tests/RenameRecordingDialogSizingTests.cs b/ytLive.Tests/RenameRecordingDialogSizingTests.cs
index 19f201a..6644b91 100644
--- a/ytLive.Tests/RenameRecordingDialogSizingTests.cs
+++ b/ytLive.Tests/RenameRecordingDialogSizingTests.cs
@@ -4,10 +4,12 @@ using Xunit;
namespace ytLive.Tests;
///
-/// The Rename Recording modal (creator-reported, 2026-09-18) cut off the file-name
-/// textbox: the fixed window was too short for its stacked content. The height is
-/// bumped 20% (230 → 276); this test renders the real dialog and asserts the textbox
-/// bottom edge lands inside the client area instead of clipping.
+/// The Rename Recording modal (the creator-reported dialog shown when a recording
+/// is ended, so it can be saved under a readable name) cut off its file-name textbox
+/// — and once the height was bumped 20% the Cancel/Save button row was revealed as
+/// previously obscured too. Height raised 230 → 304 (+20% then another +10%); this
+/// test renders the real dialog and asserts the textbox bottom edge AND the button
+/// row sit inside the client area instead of clipping.
///
[Collection("RealApp")]
public sealed class RenameRecordingDialogSizingTests
@@ -17,7 +19,7 @@ public sealed class RenameRecordingDialogSizingTests
public RenameRecordingDialogSizingTests(RealAppHost app) => _app = app;
[Fact]
- public void DialogHeight_Bumps20Percent_AndNameBoxIsNotClipped()
+ public void DialogHeight_CoversTextboxAndButtonRow_WithoutClipping()
{
_app.Run(Run);
}
@@ -27,7 +29,7 @@ public sealed class RenameRecordingDialogSizingTests
var dialog = new RenameRecordingDialog("rec-2026-09-18");
try
{
- Assert.Equal(276, dialog.Height);
+ Assert.Equal(304, dialog.Height);
dialog.Show();
dialog.UpdateLayout();
@@ -35,11 +37,18 @@ public sealed class RenameRecordingDialogSizingTests
var box = dialog.NameBox;
Assert.True(box.ActualWidth > 0, "file-name textbox must have a real layout");
var client = (FrameworkElement)dialog.Content;
- var bottom = box.TransformToAncestor(client)
+ var boxBottom = box.TransformToAncestor(client)
.TransformBounds(new Rect(box.RenderSize)).Bottom;
- Assert.True(bottom <= client.ActualHeight,
- $"textbox bottom ({bottom:0.##}) must sit inside the client area " +
+ Assert.True(boxBottom <= client.ActualHeight,
+ $"textbox bottom ({boxBottom:0.##}) must sit inside the client area " +
$"({client.ActualHeight:0.##}) — the dialog is clipping the name box");
+
+ var save = (FrameworkElement)client.FindName("SaveRecordingButton")!;
+ var saveBottom = save.TransformToAncestor(client)
+ .TransformBounds(new Rect(save.RenderSize)).Bottom;
+ Assert.True(saveBottom <= client.ActualHeight,
+ $"Save button bottom ({saveBottom:0.##}) must sit inside the client area " +
+ $"({client.ActualHeight:0.##}) — the button row is obscured");
}
finally
{