diff --git a/Controls/LeftPanel.xaml.cs b/Controls/LeftPanel.xaml.cs
index f67187d..219d116 100644
--- a/Controls/LeftPanel.xaml.cs
+++ b/Controls/LeftPanel.xaml.cs
@@ -20,6 +20,7 @@ public partial class LeftPanel : UserControl
private int _dragIndex = -1;
private bool _isDragging;
private object? _lastHoveredItem;
+ private bool _dragReordered;
public LeftPanel()
{
@@ -159,6 +160,7 @@ public partial class LeftPanel : UserControl
_dragStartPoint = e.GetPosition(listBox);
_isDragging = false;
_lastHoveredItem = null;
+ _dragReordered = false;
}
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
@@ -196,6 +198,7 @@ public partial class LeftPanel : UserControl
items.Insert(targetIndex, dragged);
_dragIndex = targetIndex;
_lastHoveredItem = targetItem;
+ _dragReordered = true;
}
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
@@ -207,9 +210,13 @@ public partial class LeftPanel : UserControl
{
if (_isDragging)
listBox.ReleaseMouseCapture();
+ var reordered = _dragReordered;
_dragIndex = -1;
_isDragging = false;
_lastHoveredItem = null;
+ _dragReordered = false;
+ if (reordered && DataContext is MainViewModel vm)
+ vm.OnSceneElementsReordered();
}
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
diff --git a/HANDOFF.md b/HANDOFF.md
index 49259ef..00f8a18 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,22 +1,17 @@
-# HANDOFF — 2026-09-17 (webcam attainability refinement committed locally at `75723ac` — no push)
+# HANDOFF — 2026-09-18 (layer drag-reorder persistence fix committed locally — no push)
## Branch / Commit State
-`main` HEAD = **`75723ac` fix(webcam): offer the Web Cam row only when a camera is attainable
-(live lock), not merely selected** (local). Below it:
+`main` HEAD = **layer drag-reorder now traps + persists** (local, this session — the new
+`LayerReorderPersistenceTests`). Below it:
+- `75723ac` fix(webcam): offer the Web Cam row only when a camera is attainable (live lock)
- `9d00955` feat(webcam): app-default gate slice — per-scene offer, Add places default directly,
identity survives removal
- `1e4017d` feat(webcam): resource lifecycle startup slice — poll-on-start, single-cam lock,
persistent Layers alert
-- `94a934f` reader-output-subtype ladder + WinRT per-call wrap rule
-- `64a5a6d` slice-18 C4 blit-on-change composite cache
-- `b37b8a3` slice-17 overlap GPU readbacks with monotonic publish gate
-**No push yet** — still awaiting the device take + user greenlight. Working tree clean.
-
-> Correcting the previous handoff, which still listed HEAD as `1e4017d`: the gate slice
-> (`9d00955`) and its attainability refinement (`75723ac`) are both committed now.
+**No push yet.** Working tree clean.
## ⚠️ Branding (2026-09-14, creator-corrected): product = **llamacasty**, internals = ytLive
@@ -63,6 +58,18 @@ the availability layer. TASK 26's app-wide gate is **superseded** by creator dir
**Task docs:** `TASKS.md` Open items + `ai.md` Webcam section updated (supersession recorded).
+## ✅ 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`
+SortOrder matches the in-memory `Elements` order.
+
## ✅ Earlier committed — webcam resource lifecycle (startup slice, `1e4017d`)
Startup poll + tri-state (`ValidateWebcamResourceStartupAsync` after `LoadLayout`): 0 → run on,
@@ -77,6 +84,9 @@ claim — `CameraConflictProbe` reads process names only, no device handles; do
## ⚠️ Open items
+- **RenameRecordingDialog too short (creator-reported, QUEUED next):** the recording save/confirm
+ dialog cuts off the file-name textbox — increase its height by ~20% (+ its one integration test,
+ Good Dog).
- **Webcam take fix 2 of 2 — the 09:16 crash is UNFIXED (hard, untested):** hypothesis — native WMF
death when `MediaCapture.Failed` fires mid-stream and `SafeStopAsync` (fire-and-forget) disposes
reader+capture while the frame thread sits in `TryAcquireLatestFrame`/`Marshal.Copy` (outside the
@@ -133,6 +143,9 @@ claim — `CameraConflictProbe` reads process names only, no device handles; do
- ffmpeg/ffprobe: `/mnt/c/Program Files/Krita (x64)/bin/` with Windows paths.
- `MyMistakes.md`: WINRT resource-allocation RECIPE, freeze-audit RECIPE, A/V sync recipe, deadline
pacing, CoreMessaging DQ, WGC-CLIP + slice blocks. Grep before re-deriving.
+- **Scene.Elements layer tests:** any seeded scene with `HasBackground=1` loads a healed
+ `Background` element pinned at `Elements[0]` (created by `EnsureBackground` on `StagedScene` set) —
+ compute expected layer orders from the live collection, never hardcode indices.
- `C:\tmpout` is for ffmpeg evidence artifacts; keep them out of the repo.
## Next step
diff --git a/ViewModels/MainViewModel.Sources.cs b/ViewModels/MainViewModel.Sources.cs
index b90705a..e2dee70 100644
--- a/ViewModels/MainViewModel.Sources.cs
+++ b/ViewModels/MainViewModel.Sources.cs
@@ -85,6 +85,15 @@ public partial class MainViewModel
UpdateActiveBackground();
}
+ /// 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.
+ public void OnSceneElementsReordered()
+ {
+ if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
+ ScheduleSave();
+ }
+
// Duplicate resource names get an incrementing suffix with no space: Image,
// Image2, Image3… The next free number is derived from the names actually in
// the scene, so deleting a middle resource never collides with a survivor.
diff --git a/ytLive.Tests/LayerReorderPersistenceTests.cs b/ytLive.Tests/LayerReorderPersistenceTests.cs
new file mode 100644
index 0000000..0e495c7
--- /dev/null
+++ b/ytLive.Tests/LayerReorderPersistenceTests.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Windows.Threading;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// Layer-list drag-to-reorder: the code-behind reorders StagedScene.Elements
+/// 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.
+///
+[Collection("RealApp")]
+public sealed class LayerReorderPersistenceTests
+{
+ private readonly RealAppHost _app;
+
+ public LayerReorderPersistenceTests(RealAppHost app) => _app = app;
+
+ [Fact]
+ public void DragReorder_SurvivesTheDebouncedBackgroundSave()
+ {
+ _app.Run(Run);
+ }
+
+ private void Run()
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-reorder-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ var window = default(MainWindow);
+ try
+ {
+ using (var schema = new LayoutStore(tempDb)) { }
+ SqliteConnection.ClearAllPools();
+ SeedThreeLayers(tempDb);
+
+ window = new MainWindow();
+ var vm = (MainViewModel)window.DataContext;
+ vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
+ Assert.True(vm.StagedScene!.Elements.OfType().Count() >= 4,
+ "3 seeded layers + the healed Background must load");
+
+ // Mirrors Controls/LeftPanel.List_PreviewMouseMove: drag the last layer
+ // to the front of the user layers (the healed Background stays pinned
+ // at index 0, so index 1 is the first user layer).
+ var elements = vm.StagedScene.Elements;
+ var dragged = elements[^1];
+ elements.RemoveAt(elements.Count - 1);
+ elements.Insert(1, dragged);
+ var expected = vm.StagedScene.Elements.OfType().Select(s => s.Name).ToArray();
+
+ vm.OnSceneElementsReordered();
+
+ Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected),
+ TimeSpan.FromSeconds(10)),
+ "the drag reorder must be persisted by the debounced background save");
+ Assert.Equal(expected, SourceNamesInDb(tempDb));
+ }
+ finally
+ {
+ if (window != null) window.Close();
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
+ }
+ }
+
+ /// Pumps the app dispatcher (so the VM's debounce timer fires) until
+ /// or the deadline passes. True when the condition
+ /// became true, false on deadline.
+ private static bool PumpUntil(Func isDone, TimeSpan timeout)
+ {
+ var frame = new DispatcherFrame();
+ var poll = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) };
+ var deadline = DateTime.UtcNow + timeout;
+ poll.Tick += (_, _) =>
+ {
+ if (isDone() || DateTime.UtcNow >= deadline)
+ {
+ poll.Stop();
+ frame.Continue = false;
+ }
+ };
+ poll.Start();
+ Dispatcher.PushFrame(frame);
+ return isDone();
+ }
+
+ private static string[] SourceNamesInDb(string path)
+ {
+ try
+ {
+ using var connection = new SqliteConnection($"Data Source={path}");
+ connection.Open();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "SELECT Name FROM Source WHERE SceneId = 'scene-live' ORDER BY SortOrder;";
+ using var reader = cmd.ExecuteReader();
+ var names = new List();
+ while (reader.Read()) names.Add(reader.GetString(0));
+ return names.ToArray();
+ }
+ catch (SqliteException)
+ {
+ // The debounced save may hold the write lock mid-transaction — not yet.
+ return [];
+ }
+ }
+
+ private static void SeedThreeLayers(string path)
+ {
+ using var connection = new SqliteConnection($"Data Source={path}");
+ connection.Open();
+
+ var scenes = new (string Id, string Name)[]
+ {
+ ("scene-starting", "Starting"),
+ ("scene-live", "Live"),
+ ("scene-brb", "BRB"),
+ ("scene-chat", "Chat"),
+ ("scene-ending", "Ending"),
+ };
+ foreach (var (id, name) in scenes)
+ {
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ($id, $name, 1, $order);";
+ cmd.Parameters.AddWithValue("$id", id);
+ cmd.Parameters.AddWithValue("$name", name);
+ cmd.Parameters.AddWithValue("$order", Array.IndexOf(scenes, (id, name)));
+ cmd.ExecuteNonQuery();
+ }
+
+ var layers = new[] { "ImgA", "ImgB", "ImgC" };
+ using (var source = connection.CreateCommand())
+ {
+ source.CommandText =
+ @"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, X, Y, Width, Height, IsBackground, SortOrder)
+ VALUES ($id, 'scene-live', 'Image', $name, 1, 0, 0, 640, 480, 0, $sort);";
+ var idP = source.Parameters.Add("$id", SqliteType.Text);
+ var nameP = source.Parameters.Add("$name", SqliteType.Text);
+ var sortP = source.Parameters.Add("$sort", SqliteType.Integer);
+ for (var i = 0; i < layers.Length; i++)
+ {
+ idP.Value = $"img-{i}";
+ nameP.Value = layers[i];
+ sortP.Value = i;
+ source.ExecuteNonQuery();
+ }
+ }
+ }
+}
\ No newline at end of file