fix(studio): per-child patch op builders and persist-seam harness (#1909)

* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)

* fix(studio): per-child patch op builders and persist-seam harness

- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml

* fix(studio): fail closed on unresolved text-field child index

buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.
This commit is contained in:
Miguel Ángel
2026-07-03 18:14:17 -07:00
committed by GitHub
parent fb13797d2f
commit 22942280b6
12 changed files with 587 additions and 21 deletions
@@ -1,7 +1,3 @@
/**
* Layer items, text fields, capabilities, selection resolution, and patch operations
* for dom editing.
*/
import type { PatchOperation } from "../../utils/sourcePatcher";
import {
resolveEditingAffordances,
@@ -36,12 +32,20 @@ import {
} from "./domEditingElement";
import { isCompositionRootLayer } from "./domEditingRootLayer";
// ─── Text fields ────────────────────────────────────────────────────────────
export function isEditableTextLeaf(el: HTMLElement): boolean {
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
}
function sameTagChildIndex(el: HTMLElement): number {
let index = 0;
let sibling = el.previousElementSibling;
while (sibling) {
if (sibling.tagName === el.tagName) index += 1;
sibling = sibling.previousElementSibling;
}
return index;
}
function getTextFieldLabel(
_tagName: string,
index: number,
@@ -57,6 +61,7 @@ function buildTextField(
index: number,
total: number,
source: "self" | "child",
sourceChildIndex?: number,
): DomEditTextField {
const tagName = el.tagName.toLowerCase();
const key = el.getAttribute("data-hf-text-key") ?? `${source}:${index}:${tagName}`;
@@ -74,6 +79,7 @@ function buildTextField(
inlineStyles: getInlineStyles(el),
computedStyles: getCuratedComputedStyles(el),
source,
...(sourceChildIndex == null ? {} : { sourceChildIndex }),
};
}
@@ -105,7 +111,9 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
});
childIdx++;
} else if (isHtmlElement(node) && isEditableTextLeaf(node)) {
fields.push(buildTextField(node, childIdx, childElements.length, "child"));
fields.push(
buildTextField(node, childIdx, childElements.length, "child", sameTagChildIndex(node)),
);
childIdx++;
}
}
@@ -113,7 +121,7 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
}
return childElements.map((child, index) =>
buildTextField(child, index, childElements.length, "child"),
buildTextField(child, index, childElements.length, "child", sameTagChildIndex(child)),
);
}
@@ -172,14 +180,30 @@ export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>):
};
}
// ─── Capabilities ────────────────────────────────────────────────────────────
export interface DomEditChildLocator {
childSelector: string;
childIndex: number;
}
export function buildTextFieldChildLocator(
fields: DomEditTextField[],
fieldKey: string,
): DomEditChildLocator | null {
const field = fields.find((candidate) => candidate.key === fieldKey);
if (!field || field.source !== "child") return null;
// sourceChildIndex is only absent for a synthetic field that was never read
// back from the live DOM (e.g. one built by buildDefaultDomEditTextField).
// Guessing its position by counting same-tag "child" fields elsewhere in
// the array is unreliable and can silently locate the wrong element — fail
// closed instead so the caller falls back to the unsupported-structure path.
if (field.sourceChildIndex == null) return null;
return {
childSelector: `:scope > ${field.tagName}`,
childIndex: field.sourceChildIndex,
};
}
/**
* Build the geometry/capability half of EditableElementFacts. Section inputs
* (text/timing/animation) are irrelevant to capability resolution, so they are
* zeroed here. Shared by the wrapper and the live-selection path so the two
* fact-construction sites can't disagree.
*/
function capabilityFacts(geometry: {
hasStableTarget: boolean;
tag: string;
@@ -276,8 +300,11 @@ async function probeSourceElement(
},
);
if (!response.ok) return true;
const data = (await response.json()) as { exists?: boolean };
return data.exists !== false;
const data = await response.json();
if (data && typeof data === "object" && "exists" in data && data.exists === false) {
return false;
}
return true;
} catch {
return true;
}
@@ -475,19 +502,28 @@ export function collectDomEditLayerItems(
// ─── Patch operations ────────────────────────────────────────────────────────
export function buildDomEditStylePatchOperation(property: string, value: string): PatchOperation {
export function buildDomEditStylePatchOperation(
property: string,
value: string | null,
childLocator?: DomEditChildLocator,
): PatchOperation {
return {
type: "inline-style",
property,
value,
...childLocator,
};
}
export function buildDomEditTextPatchOperation(value: string): PatchOperation {
export function buildDomEditTextPatchOperation(
value: string,
childLocator?: DomEditChildLocator,
): PatchOperation {
return {
type: "text-content",
property: "text",
value,
...childLocator,
};
}