fix(studio): persist canvas z-order actions correctly for static elements

An adversarial review of the canvas context-menu z-order pipeline (Bring to
Front / Forward / Backward / Send to Back) found the resolver math sound but
the glue between the menu and the commit hook broken:

- The menu optimistically wrote style.zIndex AND position: relative to the
  live elements BEFORE the commit hook ran. The hook decides whether to
  persist position by checking getComputedStyle(el).position === 'static' —
  always false after the pre-apply — so the position patch was never
  persisted on the menu path and the reorder silently reverted at the
  post-commit reload for any nested/static element (root clips survive only
  because the runtime forces position:absolute). The same pre-apply made the
  failure rollback capture the already-mutated values, restoring the broken
  state on persist errors. The menu no longer pre-applies; the hook owns the
  live writes (it already applied both synchronously) and now sees true
  priors. Siblings without a persistable identity still get their z applied
  live-only so a renumber stays visually coherent.
- The commit hook's entry.key store-sync plumbing had zero production
  callers; the store zIndex went stale until full reload. All three callers
  (canvas menu via PreviewOverlays, timeline lane z-sync, LayersPanel) now
  derive and pass the timeline store key (new deriveTimelineStoreKey helper).
- patchElementBatch discarded the server's per-patch matched[]; unresolvable
  siblings persisted partially and silently. Unmatched targets now warn and
  report save-failure telemetry (z-reorder-unmatched) without rolling back
  the matched subset.
- template/noscript elements counted as painting siblings, so renumber
  fallbacks wrote z-index/position into <template> tags in the source file.
  Excluded from the sibling family.
- The default undo coalesce key merged DISTINCT z actions within 300ms into
  one undo entry; the action kind is now part of the key (LayersPanel drags
  keep coalescing within a drag; explicit lane-move gesture keys untouched).
- rectsIntersect comment claimed touching rects intersect; the strict
  inequalities say otherwise — comment fixed.
This commit is contained in:
ukimsanov
2026-07-13 16:48:52 -07:00
parent 19139b91ed
commit 84963ea8ba
14 changed files with 491 additions and 47 deletions
@@ -344,6 +344,60 @@ describe("useDomEditCommits z-index reorder persistence", () => {
}
});
it("warns and reports telemetry for unmatched batch patches without throwing", async () => {
// The server reports per-patch matched[]: #b was not found in the source.
// The matched subset persisted, so the commit must complete (no rollback of
// applied state) while surfacing the partial failure.
const original = '<div id="a" style="z-index: 1"></div>';
const after = '<div id="a" style="z-index: 2"></div>';
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
const url = requestUrl(input);
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
if (url.includes("/file-mutations/patch-elements-batch/")) {
return jsonResponse({ ok: true, changed: true, matched: [true, false], content: after });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { iframe, element } = createPreviewElement(
'<div data-hf-id="hf-card"></div><div id="b"></div>',
);
element.id = "a";
const second = iframe.contentDocument!.getElementById("b")!;
const rendered = renderDomEditCommits(createSelection(element), iframe);
try {
await act(async () => {
await rendered.hook.handleDomZIndexReorderCommit([
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
{ element: second, zIndex: 1, id: "b", sourceFile: "index.html" },
]);
});
// No throw: the applied live state stays, the matched subset is recorded.
expect(element.style.zIndex).toBe("2");
expect(second.style.zIndex).toBe("1");
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("could not match 1 patch target(s) in index.html"),
"b",
);
expect(trackStudioEvent).toHaveBeenCalledWith(
"save_failure",
expect.objectContaining({
mutation_type: "z-reorder-unmatched",
file_path: "index.html",
error_message: expect.stringContaining("b"),
}),
);
} finally {
warnSpy.mockRestore();
rendered.cleanup();
}
});
it("rolls back live state after a failed batch POST without a disk write-back", async () => {
const original = '<div id="a" style="z-index: 7"></div><div id="b"></div>';
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {