diff --git a/packages/core/src/parsers/hfIds.test.ts b/packages/core/src/parsers/hfIds.test.ts
index a409fbbaf..359082349 100644
--- a/packages/core/src/parsers/hfIds.test.ts
+++ b/packages/core/src/parsers/hfIds.test.ts
@@ -100,9 +100,9 @@ describe("ensureHfIds", () => {
});
// Lock the edit-lifecycle behavior. These pin BOTH the guarantee that holds
-// once ids are persisted to source (pinning) AND the two limitations that hold
-// while they are not (design §3 write-back is not yet wired — see
-// notes/r1-stable-hf-ids-design.md "Implementation status & verified lifecycle gap").
+// once ids are persisted to source (pinning) AND the behavior for truly unpinned
+// HTML (no data-hf-id in the input — unreachable in production after write-back
+// landed in R7 Task 1-2, but still the correct contract for that path).
describe("ensureHfIds — edit lifecycle (R1 stability)", () => {
it("pinned id survives a content edit (the §3 write-back guarantee)", () => {
// Element already carries data-hf-id in source (as it would after write-back).
@@ -110,25 +110,24 @@ describe("ensureHfIds — edit lifecycle (R1 stability)", () => {
expect(idOf(ensureHfIds(edited), "p.body")).toBe("hf-abcd");
});
- it("KNOWN LIMITATION: an unpinned id changes when the element's text is edited", () => {
- // No data-hf-id in source → every parse re-mints from content. Editing the
- // text changes the hash, so the id drifts. This is the "pure-hash" mode the
- // design rejected; flip this assertion to .toBe once write-back lands.
+ it("unpinned id drifts when element text is edited (pure-hash, unreachable after write-back)", () => {
+ // No data-hf-id in source → every parse re-mints from content. This path is
+ // unreachable in production after R7 write-back: the first serve pins the id.
const before = idOf(ensureHfIds(doc(`
Hello
`)), "p.body");
const after = idOf(ensureHfIds(doc(`Hello world
`)), "p.body");
expect(before).not.toBe(after);
});
- it("KNOWN LIMITATION: an unpinned id changes when an attribute is edited", () => {
+ it("unpinned id drifts when attribute is edited (pure-hash, unreachable after write-back)", () => {
const before = idOf(ensureHfIds(doc(`x
`)), "p");
const after = idOf(ensureHfIds(doc(`x
`)), "p");
expect(before).not.toBe(after);
});
- it("KNOWN LIMITATION: identical-content siblings have no content-stable id for the 2nd occurrence", () => {
+ it("identical-content siblings: second occurrence gets a position-derived dedup id", () => {
// Insertion stability holds for DISTINCT content (covered elsewhere), but a
- // second identical sibling collides and gets a position-derived dedup id —
- // there is no content-stable handle for it. The first keeps the base id.
+ // second identical sibling collides and gets a position-derived dedup id.
+ // First element keeps the base (content-derived) id; documented in project_hfid_dedup_tiebreak.
const single = idOf(ensureHfIds(doc(`same
`)), "p.x");
const pair = ids(ensureHfIds(doc(`same
same
`)));
expect(pair[0]).toBe(single); // first identical element: stable, content-derived
diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts
index 8915d411d..4aff09247 100644
--- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts
+++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts
@@ -3,6 +3,7 @@ import {
removeElementFromHtml,
patchElementInHtml,
probeElementInSource,
+ splitElementInHtml,
} from "./sourceMutation.js";
describe("removeElementFromHtml", () => {
@@ -455,3 +456,14 @@ describe("T7 — data-hf-id targeting (spec for R1)", () => {
expect(html).toContain('data-hf-id="hf-a1b2"');
});
});
+
+describe("splitElementInHtml — hfId clone isolation", () => {
+ it("does not copy data-hf-id to the cloned second half", () => {
+ const source = ``;
+ const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2");
+
+ expect(matched).toBe(true);
+ const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length;
+ expect(occurrences).toBe(1);
+ });
+});
diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts
index d7c2c2ef7..ff455408b 100644
--- a/packages/core/src/studio-api/helpers/sourceMutation.ts
+++ b/packages/core/src/studio-api/helpers/sourceMutation.ts
@@ -275,6 +275,7 @@ export function splitElementInHtml(
const clone = el.cloneNode(true) as HTMLElement;
clone.setAttribute("id", newId);
+ clone.removeAttribute("data-hf-id");
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
clone.setAttribute("data-duration", String(Math.round(secondDuration * 1000) / 1000));
diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.test.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.test.ts
new file mode 100644
index 000000000..2bd16d839
--- /dev/null
+++ b/packages/studio/src/components/editor/domEditOverlayGeometry.test.ts
@@ -0,0 +1,13 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from "vitest";
+import { selectionCacheKey } from "./domEditOverlayGeometry";
+
+describe("selectionCacheKey — hfId collision (R7)", () => {
+ it("produces distinct keys for two elements that differ only by hfId", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const a = selectionCacheKey({ sourceFile: "index.html", hfId: "hf-111" } as any);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const b = selectionCacheKey({ sourceFile: "index.html", hfId: "hf-222" } as any);
+ expect(a).not.toBe(b);
+ });
+});
diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.ts
index bfebd852b..f3a3fc1d3 100644
--- a/packages/studio/src/components/editor/domEditOverlayGeometry.ts
+++ b/packages/studio/src/components/editor/domEditOverlayGeometry.ts
@@ -190,10 +190,11 @@ export function filterNestedDomEditGroupItems,
+ selection: Pick,
): string {
return [
selection.sourceFile ?? "",
+ selection.hfId ?? "",
selection.id ?? "",
selection.selector ?? "",
selection.selectorIndex ?? "",
diff --git a/packages/studio/src/components/editor/domEditing.test.ts b/packages/studio/src/components/editor/domEditing.test.ts
index 370e9c096..cb663c386 100644
--- a/packages/studio/src/components/editor/domEditing.test.ts
+++ b/packages/studio/src/components/editor/domEditing.test.ts
@@ -1156,3 +1156,46 @@ describe("patch builders and prompt builder", () => {
).not.toThrow();
});
});
+
+describe("hfId — find, key, capabilities (R7 fixes)", () => {
+ it("getDomEditTargetKey keeps two hfId-only elements distinct", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const a = getDomEditTargetKey({ sourceFile: "index.html", hfId: "hf-aaa" } as any);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const b = getDomEditTargetKey({ sourceFile: "index.html", hfId: "hf-bbb" } as any);
+ expect(a).not.toBe(b);
+ });
+
+ it("findElementForSelection finds element by data-hf-id when no id or selector", () => {
+ const doc = createDocument(`
+
+ `);
+ const el = doc.querySelector('[data-hf-id="hf-xyz789"]') as HTMLElement;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const found = findElementForSelection(doc, { hfId: "hf-xyz789" } as any);
+ expect(found).toBe(el);
+ });
+
+ it("resolveDomEditCapabilities enables editing for hfId-only element (no CSS selector)", () => {
+ const result = resolveDomEditCapabilities({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ hfId: "hf-abc" as any,
+ selector: undefined,
+ inlineStyles: { left: "10px", top: "20px", width: "100px", height: "50px" },
+ computedStyles: {
+ position: "absolute",
+ left: "10px",
+ top: "20px",
+ width: "100px",
+ height: "50px",
+ },
+ isCompositionHost: false,
+ isInsideLockedComposition: false,
+ isMasterView: false,
+ });
+ expect(result.canSelect).toBe(true);
+ expect(result.canMove).toBe(true);
+ });
+});
diff --git a/packages/studio/src/components/editor/domEditing.ts b/packages/studio/src/components/editor/domEditing.ts
index d8a16344e..45927cfe7 100644
--- a/packages/studio/src/components/editor/domEditing.ts
+++ b/packages/studio/src/components/editor/domEditing.ts
@@ -35,6 +35,7 @@ export {
getDomEditNonEditableReason,
getDomEditTargetKey,
isTextEditableSelection,
+ readHfId,
refreshDomEditSelection,
resolveDomEditCapabilities,
resolveDomEditSelection,
diff --git a/packages/studio/src/components/editor/domEditingElement.ts b/packages/studio/src/components/editor/domEditingElement.ts
index b081d6498..b4ca30113 100644
--- a/packages/studio/src/components/editor/domEditingElement.ts
+++ b/packages/studio/src/components/editor/domEditingElement.ts
@@ -95,7 +95,7 @@ function isInspectableLayerElement(el: HTMLElement): boolean {
export function getDomLayerPatchTarget(
el: HTMLElement,
activeCompositionPath: string | null,
-): Pick | null {
+): Pick | null {
if (!isInspectableLayerElement(el)) return null;
if (el.hasAttribute("data-composition-id")) return null;
@@ -105,6 +105,7 @@ export function getDomLayerPatchTarget(
const { sourceFile } = getSourceFileForElement(el, activeCompositionPath);
return {
id: el.id || undefined,
+ hfId: el.getAttribute("data-hf-id") || undefined,
selector,
selectorIndex: getSelectorIndex(
el.ownerDocument,
@@ -229,9 +230,14 @@ export function isLargeRasterDomEditSelection(
export function findElementForSelection(
doc: Document,
- selection: Pick,
+ selection: Pick,
activeCompositionPath: string | null = null,
): HTMLElement | null {
+ if (selection.hfId) {
+ const byHfId = doc.querySelector(`[data-hf-id="${selection.hfId}"]`);
+ if (isHtmlElement(byHfId)) return byHfId;
+ }
+
if (selection.id) {
const byId = doc.getElementById(selection.id);
if (
diff --git a/packages/studio/src/components/editor/domEditingLayers.test.ts b/packages/studio/src/components/editor/domEditingLayers.test.ts
index 900b2aea6..beb7fb480 100644
--- a/packages/studio/src/components/editor/domEditingLayers.test.ts
+++ b/packages/studio/src/components/editor/domEditingLayers.test.ts
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
-import { resolveDomEditSelection, buildDomEditPatchTarget } from "./domEditingLayers";
+import { resolveDomEditSelection, buildDomEditPatchTarget, readHfId } from "./domEditingLayers";
const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true };
@@ -27,6 +27,31 @@ describe("buildDomEditPatchTarget", () => {
});
});
+describe("readHfId", () => {
+ it("returns the attribute value when present", () => {
+ const el = document.createElement("div");
+ el.setAttribute("data-hf-id", "hf-abc");
+ expect(readHfId(el)).toBe("hf-abc");
+ });
+
+ it("returns undefined when attribute is absent", () => {
+ const el = document.createElement("div");
+ expect(readHfId(el)).toBeUndefined();
+ });
+
+ it("returns undefined when attribute is empty string", () => {
+ const el = document.createElement("div");
+ el.setAttribute("data-hf-id", "");
+ expect(readHfId(el)).toBeUndefined();
+ });
+
+ it("returns undefined when attribute is whitespace-only", () => {
+ const el = document.createElement("div");
+ el.setAttribute("data-hf-id", " ");
+ expect(readHfId(el)).toBeUndefined();
+ });
+});
+
describe("resolveDomEditSelection — hfId from data-hf-id", () => {
it("populates hfId from the element data-hf-id attribute", async () => {
const el = document.createElement("div");
diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts
index 4f3195389..8b3fa73b7 100644
--- a/packages/studio/src/components/editor/domEditingLayers.ts
+++ b/packages/studio/src/components/editor/domEditingLayers.ts
@@ -173,6 +173,7 @@ export function buildDefaultDomEditTextField(base?: Partial):
// fallow-ignore-next-line complexity
export function resolveDomEditCapabilities(args: {
selector?: string;
+ hfId?: string;
tagName?: string;
className?: string;
inlineStyles: Record;
@@ -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 {
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,
+ selection: Pick,
): 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,
): { id?: string | null; hfId?: string; selector?: string; selectorIndex?: number } {
diff --git a/packages/studio/src/components/editor/domEditingTypes.ts b/packages/studio/src/components/editor/domEditingTypes.ts
index 96f69ff8c..50d82cafa 100644
--- a/packages/studio/src/components/editor/domEditingTypes.ts
+++ b/packages/studio/src/components/editor/domEditingTypes.ts
@@ -98,6 +98,7 @@ export interface DomEditLayerItem {
depth: number;
childCount: number;
id?: string;
+ hfId?: string;
selector?: string;
selectorIndex?: number;
sourceFile: string;
diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts
index 2d53c1a03..752b0fff2 100644
--- a/packages/studio/src/hooks/useDomEditCommits.ts
+++ b/packages/studio/src/hooks/useDomEditCommits.ts
@@ -8,6 +8,7 @@ import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
import {
buildDomEditPatchTarget,
getDomEditTargetKey,
+ readHfId,
type DomEditSelection,
} from "../components/editor/domEditing";
import {
@@ -533,7 +534,7 @@ export function useDomEditCommits({
}>,
) => {
if (entries.length === 0) return;
- const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? "el").join(":")}`;
+ const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
entry.element.style.zIndex = String(entry.zIndex);
@@ -553,7 +554,7 @@ export function useDomEditCommits({
{
element: entry.element,
id: entry.id ?? null,
- hfId: entry.element.getAttribute("data-hf-id") ?? undefined,
+ hfId: readHfId(entry.element),
selector: entry.selector,
selectorIndex: entry.selectorIndex,
sourceFile: entry.sourceFile,
diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts
index 4c5908f4a..89b21c1d5 100644
--- a/packages/studio/src/hooks/useGsapScriptCommits.ts
+++ b/packages/studio/src/hooks/useGsapScriptCommits.ts
@@ -285,6 +285,7 @@ export function useGsapScriptCommits({
body: JSON.stringify({
target: {
id: selection.id,
+ hfId: selection.hfId,
selector: selection.selector,
selectorIndex: selection.selectorIndex,
},
diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts
index f2b7c032a..53d01ee76 100644
--- a/packages/studio/src/hooks/useTimelineEditing.ts
+++ b/packages/studio/src/hooks/useTimelineEditing.ts
@@ -45,9 +45,22 @@ interface UseTimelineEditingOptions {
// ── Helpers ──
-function buildPatchTarget(element: { domId?: string; selector?: string; selectorIndex?: number }) {
+function buildPatchTarget(element: {
+ domId?: string;
+ hfId?: string;
+ selector?: string;
+ selectorIndex?: number;
+}) {
if (element.domId) {
- return { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex };
+ return {
+ id: element.domId,
+ hfId: element.hfId,
+ selector: element.selector,
+ selectorIndex: element.selectorIndex,
+ };
+ }
+ if (element.hfId) {
+ return { hfId: element.hfId, selector: element.selector, selectorIndex: element.selectorIndex };
}
if (element.selector) {
return { selector: element.selector, selectorIndex: element.selectorIndex };
diff --git a/packages/studio/src/player/lib/timelineDOM.test.ts b/packages/studio/src/player/lib/timelineDOM.test.ts
new file mode 100644
index 000000000..69fdfdfc3
--- /dev/null
+++ b/packages/studio/src/player/lib/timelineDOM.test.ts
@@ -0,0 +1,55 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from "vitest";
+import { parseTimelineFromDOM, createImplicitTimelineLayersFromDOM } from "./timelineDOM";
+
+function makeDoc(html: string): Document {
+ const d = document.implementation.createHTMLDocument();
+ d.body.innerHTML = html;
+ return d;
+}
+
+describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
+ it("harvests hfId from a data-start element that has data-hf-id", () => {
+ const doc = makeDoc(`
+
+ `);
+
+ const elements = parseTimelineFromDOM(doc, 10);
+ const hero = elements.find((el) => el.domId === "hero");
+
+ expect(hero).toBeDefined();
+ expect(hero?.hfId).toBe("hf-abc123");
+ });
+
+ it("leaves hfId undefined when element has no data-hf-id", () => {
+ const doc = makeDoc(`
+
+ `);
+
+ const elements = parseTimelineFromDOM(doc, 10);
+ const plain = elements.find((el) => el.domId === "plain");
+
+ expect(plain).toBeDefined();
+ expect(plain?.hfId).toBeUndefined();
+ });
+});
+
+describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
+ it("harvests hfId from an implicit layer child that has data-hf-id", () => {
+ const doc = makeDoc(`
+
+ `);
+
+ const layers = createImplicitTimelineLayersFromDOM(doc, 10);
+ const layer = layers.find((el) => el.domId === "layer");
+
+ expect(layer).toBeDefined();
+ expect(layer?.hfId).toBe("hf-xyz789");
+ });
+});
diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts
index 963832359..215d419ec 100644
--- a/packages/studio/src/player/lib/timelineDOM.ts
+++ b/packages/studio/src/player/lib/timelineDOM.ts
@@ -26,14 +26,21 @@ import {
// Re-export helpers that were previously public from this module so that
// existing import sites (hook + tests) don't need to change.
+// fallow-ignore-next-line unused-exports
export {
readTimelineDurationFromDocument,
+ // fallow-ignore-next-line unused-exports
resolveMediaElement,
+ // fallow-ignore-next-line unused-exports
applyMediaMetadataFromElement,
getTimelineElementSelector,
+ // fallow-ignore-next-line unused-exports
getTimelineElementSourceFile,
+ // fallow-ignore-next-line unused-exports
getTimelineElementSelectorIndex,
+ // fallow-ignore-next-line unused-exports
buildTimelineElementIdentity,
+ // fallow-ignore-next-line unused-exports
getTimelineElementIdentity,
findTimelineDomNodeForClip,
} from "./timelineElementHelpers";
@@ -72,8 +79,10 @@ export function createTimelineElementFromManifestClip(params: {
let selectorIndex: number | undefined;
let sourceFile: string | undefined;
+ let hfId: string | undefined;
if (hostEl) {
domId = hostEl.id || undefined;
+ hfId = hostEl.getAttribute("data-hf-id") || undefined;
selector = getTimelineElementSelector(hostEl);
selectorIndex =
doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
@@ -98,6 +107,7 @@ export function createTimelineElementFromManifestClip(params: {
duration: clip.duration,
track: clip.track,
domId,
+ hfId,
selector,
selectorIndex,
sourceFile,
@@ -127,6 +137,7 @@ export function createTimelineElementFromManifestClip(params: {
}
if (hostEl) {
entry.domId = hostEl.id || undefined;
+ entry.hfId = hostEl.getAttribute("data-hf-id") || undefined;
entry.selector = getTimelineElementSelector(hostEl);
entry.selectorIndex =
doc && entry.selector
@@ -187,6 +198,7 @@ export function createImplicitTimelineLayersFromDOM(
layers.push({
domId: child.id || undefined,
+ hfId: child.getAttribute("data-hf-id") || undefined,
duration: rootDuration,
id: identity.id,
key: identity.key,
@@ -262,6 +274,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
duration: dur,
track: isNaN(track) ? 0 : track,
domId: el.id || undefined,
+ hfId: el.getAttribute("data-hf-id") || undefined,
selector,
selectorIndex,
sourceFile,
diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts
new file mode 100644
index 000000000..b97bf498e
--- /dev/null
+++ b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts
@@ -0,0 +1,51 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from "vitest";
+import { buildMissingCompositionElements } from "./timelineIframeHelpers";
+import type { IframeWindow } from "./playbackTypes";
+
+function makeDoc(html: string): Document {
+ const d = document.implementation.createHTMLDocument();
+ d.body.innerHTML = html;
+ return d;
+}
+
+describe("buildMissingCompositionElements — hfId (R7)", () => {
+ it("harvests hfId from data-hf-id on composition host elements", () => {
+ const doc = makeDoc(`
+
+ `);
+
+ const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
+ const entry = missing[0];
+
+ expect(entry).toBeDefined();
+ expect(entry?.hfId).toBe("hf-scene1");
+ });
+
+ it("leaves hfId undefined when element has no data-hf-id", () => {
+ const doc = makeDoc(`
+
+ `);
+
+ const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
+ const entry = missing[0];
+
+ expect(entry).toBeDefined();
+ expect(entry?.hfId).toBeUndefined();
+ });
+});
diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.ts b/packages/studio/src/player/lib/timelineIframeHelpers.ts
index 7bb824bdb..a4ba94d94 100644
--- a/packages/studio/src/player/lib/timelineIframeHelpers.ts
+++ b/packages/studio/src/player/lib/timelineIframeHelpers.ts
@@ -286,6 +286,7 @@ export function buildMissingCompositionElements(
duration: dur,
track: isNaN(track) ? 0 : track,
domId: el.id || undefined,
+ hfId: el.getAttribute("data-hf-id") || undefined,
selector,
selectorIndex,
sourceFile,
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index d67aad396..3382fb945 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -22,6 +22,8 @@ export interface TimelineElement {
duration: number;
track: number;
domId?: string;
+ /** Stable `data-hf-id` attribute value — used as primary patch target when present */
+ hfId?: string;
/** Best-effort selector used when patching source HTML back from timeline edits */
selector?: string;
/** Zero-based occurrence index for non-unique selectors */
diff --git a/packages/studio/src/utils/sourcePatcher.ts b/packages/studio/src/utils/sourcePatcher.ts
index 9d19114d4..f8ee9d67f 100644
--- a/packages/studio/src/utils/sourcePatcher.ts
+++ b/packages/studio/src/utils/sourcePatcher.ts
@@ -92,6 +92,8 @@ export interface PatchOperation {
value: string | null;
}
+// Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
+// escape). This type is documentation only; the server's MutationTarget mirrors this shape.
export interface PatchTarget {
id?: string | null;
hfId?: string;