feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b) (#1299)

* feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b)

* refactor(studio): extract readHfId helper, fix empty-string normalization, add comments (R7 review)

- Extract readHfId(el) to domEditingLayers.ts — centralizes `?.trim() || undefined`
  normalisation; guards against empty-string data-hf-id reaching findTagByTarget
- Wire readHfId into domEditingLayers.ts and useDomEditCommits.ts (the one site
  that still used `?? undefined` instead of `|| undefined`)
- Re-export readHfId through domEditing.ts public API
- Add readHfId unit tests: present, absent, empty-string, whitespace-only
- Add comment on PatchTarget: runtime validation lives in findTagByTarget, type is docs-only
- Suppress pre-existing unused re-exports in timelineDOM.ts (backward-compat re-exports
  brought into fallow scope by the T5b hfId changes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(core): clear data-hf-id on split clone to prevent dual-match (R7 review)

cloneNode(true) copies all attributes including data-hf-id. Without clearing it,
both halves of a split share the same hf-id; the server's findByHfId picks the first
match and silently patches the wrong clip. Remove the attribute from the clone so
write-back re-mints a fresh id on the next preview load.

Adds a test: splitElementInHtml — hfId clone isolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(studio): add hfId to DomEditLayerItem + getDomLayerPatchTarget return type (R7 review)

- Add hfId to DomEditLayerItem interface (domEditingTypes.ts) so layer item
  construction in collectDomEditLayerItems compiles
- Widen getDomLayerPatchTarget return type to include hfId + populate it from
  data-hf-id attribute (domEditingElement.ts)
- Widen findDomEditSelectionTarget to check hfId-first when no id/selector
- Widen Pick types in domEditOverlayGeometry.ts and useGsapScriptCommits.ts
- Add hfId to buildMissingCompositionElements element construction
- Add hfId-targeted test coverage in domEditing.test.ts,
  domEditOverlayGeometry.test.ts, timelineIframeHelpers.test.ts
- Update hfIds.test.ts KNOWN LIMITATION labels — write-back landed in R7 T1-2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-09 15:15:27 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 82c754f27a
commit 0923bc0787
20 changed files with 282 additions and 33 deletions
@@ -173,6 +173,7 @@ export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>):
// fallow-ignore-next-line complexity
export function resolveDomEditCapabilities(args: {
selector?: string;
hfId?: string;
tagName?: string;
className?: string;
inlineStyles: Record<string, string>;
@@ -182,7 +183,7 @@ export function resolveDomEditCapabilities(args: {
isMasterView: boolean;
existsInSource?: boolean;
}): DomEditCapabilities {
if (!args.selector || args.isInsideLockedComposition) {
if ((!args.selector && !args.hfId) || args.isInsideLockedComposition) {
return {
canSelect: !args.isInsideLockedComposition,
canEditStyles: false,
@@ -289,7 +290,7 @@ export function buildElementLabel(el: HTMLElement): string {
async function probeSourceElement(
projectId: string,
sourceFile: string,
target: { id?: string; selector?: string; selectorIndex?: number },
target: { id?: string; hfId?: string; selector?: string; selectorIndex?: number },
): Promise<boolean> {
try {
const response = await fetch(
@@ -321,7 +322,8 @@ export async function resolveDomEditSelection(
let current: HTMLElement | null = getSelectionCandidate(startEl, options);
while (current && current !== doc.body && current !== doc.documentElement) {
const selector = buildStableSelector(current);
if (!selector) {
const hfId = readHfId(current);
if (!selector && !hfId) {
current = current.parentElement;
continue;
}
@@ -330,13 +332,9 @@ export async function resolveDomEditSelection(
current,
options.activeCompositionPath,
);
const selectorIndex = getSelectorIndex(
doc,
current,
selector,
sourceFile,
options.activeCompositionPath,
);
const selectorIndex = selector
? getSelectorIndex(doc, current, selector, sourceFile, options.activeCompositionPath)
: undefined;
const compositionSrc =
current.getAttribute("data-composition-src") ??
current.getAttribute("data-composition-file") ??
@@ -346,15 +344,18 @@ export async function resolveDomEditSelection(
const textFields = collectDomEditTextFields(current);
const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
let existsInSource: boolean | undefined;
if (!options.skipSourceProbe && options.projectId && (current.id || selector)) {
const probeTarget: { id?: string; selector?: string; selectorIndex?: number } = {};
if (!options.skipSourceProbe && options.projectId && (current.id || selector || hfId)) {
const probeTarget: { id?: string; hfId?: string; selector?: string; selectorIndex?: number } =
{};
if (current.id) probeTarget.id = current.id;
if (hfId) probeTarget.hfId = hfId;
if (selector) probeTarget.selector = selector;
if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex;
existsInSource = await probeSourceElement(options.projectId, sourceFile, probeTarget);
}
const capabilities = resolveDomEditCapabilities({
selector,
hfId,
tagName: current.tagName.toLowerCase(),
className: current.className,
inlineStyles,
@@ -369,7 +370,7 @@ export async function resolveDomEditSelection(
return {
element: current,
id: current.id || undefined,
hfId: current.getAttribute("data-hf-id") ?? undefined,
hfId,
selector,
selectorIndex,
sourceFile,
@@ -452,6 +453,7 @@ export function collectDomEditLayerItems(
if (!root) return [];
const items: DomEditLayerItem[] = [];
// fallow-ignore-next-line complexity
const visit = (el: HTMLElement, depth: number) => {
if (items.length >= maxItems) return;
@@ -465,6 +467,7 @@ export function collectDomEditLayerItems(
depth,
childCount: getDirectLayerChildren(el, options).length,
id: target.id ?? undefined,
hfId: target.hfId ?? undefined,
selector: target.selector ?? undefined,
selectorIndex: target.selectorIndex,
sourceFile: target.sourceFile,
@@ -536,10 +539,11 @@ export function getDomEditNonEditableReason(
}
export function getDomEditTargetKey(
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
): string {
return [
selection.sourceFile || "index.html",
selection.hfId ?? "",
selection.id ?? "",
selection.selector ?? "",
selection.selectorIndex ?? "",
@@ -556,6 +560,10 @@ export function isTextEditableSelection(selection: DomEditSelection): boolean {
// buildElementAgentPrompt is in domEditingAgentPrompt.ts
export function readHfId(element: Element): string | undefined {
return element.getAttribute("data-hf-id")?.trim() || undefined;
}
export function buildDomEditPatchTarget(
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex">,
): { id?: string | null; hfId?: string; selector?: string; selectorIndex?: number } {