Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@
"import": "./src/studio-api/helpers/studioMotionRenderScript.ts",
"types": "./src/studio-api/helpers/studioMotionRenderScript.ts"
},
"./studio-api/draft-markers": {
"import": "./src/studio-api/helpers/draftMarkers.ts",
"types": "./src/studio-api/helpers/draftMarkers.ts"
},
"./text": {
"import": "./src/text/index.ts",
"types": "./src/text/index.ts"
Expand Down Expand Up @@ -121,6 +125,10 @@
"import": "./dist/studio-api/helpers/studioMotionRenderScript.js",
"types": "./dist/studio-api/helpers/studioMotionRenderScript.d.ts"
},
"./studio-api/draft-markers": {
"import": "./dist/studio-api/helpers/draftMarkers.js",
"types": "./dist/studio-api/helpers/draftMarkers.d.ts"
},
"./text": {
"import": "./dist/text/index.js",
"types": "./dist/text/index.d.ts"
Expand Down
14 changes: 8 additions & 6 deletions packages/core/src/parsers/hfIds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,14 @@ describe("ensureHfIds", () => {
expect(ids(ensureHfIds(edited))).toContain(originalId);
});

it("pinned id survives attribute edit after first persist", () => {
const raw = `<!doctype html><html><body><div class="old">text</div></body></html>`;
const persisted = ensureHfIds(raw); // simulates write-back on first serve
const [originalId] = ids(persisted);
const edited = persisted.replace('class="old"', 'class="new"');
expect(ids(ensureHfIds(edited))).toContain(originalId);
// Hash-based stability (no prior pin): the same element content yields the
// same id regardless of what sibling elements appear in the document.
it("content-keyed minting is stable: same element content → same id in different documents", () => {
const alone = `<!doctype html><html><body><div class="card">hello</div></body></html>`;
const [idAlone] = ids(ensureHfIds(alone));
// The <div class="card">hello</div> appears alongside a new sibling here.
const withSibling = `<!doctype html><html><body><span>prefix</span><div class="card">hello</div></body></html>`;
expect(ids(ensureHfIds(withSibling))).toContain(idAlone);
});
});

Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/parsers/hfIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ function contentKey(el: Element): string {
* (`if (el.getAttribute("data-hf-id")) continue`), so normal operation
* never re-exposes the ordering after first persist.
*/
// WIRE CONTRACT: id minting is content-keyed (FNV1a of innerHTML + tag). R7's
// preview route relies on mintHfId producing identical ids across mint contexts
// (disk-persist pass vs. in-memory bundle pass) — see preview.test.ts
// "bundle returning untagged HTML gets same ids as disk". Any change that adds
// positional, session, or random input to the hash breaks that invariant and
// makes hf- ids diverge between disk and served HTML, silently corrupting
// drag-to-edit targeting.
export function mintHfId(el: Element, assigned: Set<string>): string {
const key = contentKey(el);
let id = toHfId(fnv1a(key));
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/studio-api/helpers/draftMarkers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Draft-marker constants shared between core's PreviewAdapter and Studio's
* manual-edits code. CSS custom properties written during a drag gesture, plus
* the gesture marker attribute. Exported from @hyperframes/core/studio-api/draft-markers.
*/
export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x";
export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y";
export const STUDIO_WIDTH_PROP = "--hf-studio-width";
export const STUDIO_HEIGHT_PROP = "--hf-studio-height";
export const STUDIO_MANUAL_EDIT_GESTURE_ATTR = "data-hf-studio-manual-edit-gesture";
42 changes: 22 additions & 20 deletions packages/core/src/studio-api/helpers/hfIdPersist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { normalizeHfIds, persistHfIdsIfNeeded } from "./hfIdPersist.js";

describe("normalizeHfIds", () => {
it("marks changed=true and adds data-hf-id to all body elements when untagged", () => {
const raw = `<!doctype html><html><body><div><p>hello</p></div></body></html>`;
const { html, changed } = normalizeHfIds(raw);
expect(changed).toBe(true);
expect(html).toContain('data-hf-id="hf-');
const matches = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g);
expect(matches?.length).toBeGreaterThanOrEqual(2);
});

it("marks changed=false for already-normalized HTML (idempotent round-trip)", () => {
const raw = `<!doctype html><html><body><div><p>hello</p></div></body></html>`;
const first = normalizeHfIds(raw).html;
const { html, changed } = normalizeHfIds(first);
expect(changed).toBe(false);
expect(html).toBe(first);
});
});
import { persistHfIdsIfNeeded } from "./hfIdPersist.js";

describe("persistHfIdsIfNeeded", () => {
const tmpDirs: string[] = [];
Expand Down Expand Up @@ -59,11 +40,32 @@ describe("persistHfIdsIfNeeded", () => {
expect(readFileSync(file, "utf-8")).toBe(diskAfterFirst);
});

it("does not rewrite when source is already tagged with non-standard HTML formatting", () => {
// Single-quoted attrs would cause a false-positive write under string-equality
// change detection; count-based detection handles this correctly.
const alreadyTagged = `<!doctype html><html><body><div data-hf-id='hf-ab12'>hello</div></body></html>`;
const file = tmpFile(alreadyTagged);
persistHfIdsIfNeeded(file, alreadyTagged);
expect(readFileSync(file, "utf-8")).toBe(alreadyTagged);
});

it("returned id matches id written to disk (serve-time == persist-time invariant)", () => {
const raw = `<!doctype html><html><body><span>text</span></body></html>`;
const file = tmpFile(raw);
const result = persistHfIdsIfNeeded(file, raw);
const onDisk = readFileSync(file, "utf-8");
expect(result).toBe(onDisk);
});

it("skips write if file was modified concurrently (TOCTOU guard)", () => {
const old = `<!doctype html><html><body><div>original</div></body></html>`;
const newer = `<!doctype html><html><body><div>modified by user</div></body></html>`;
// Disk has newer content — simulates a concurrent save after the server read old.
const file = tmpFile(newer);
const returned = persistHfIdsIfNeeded(file, old);
// Serve-time HTML gets ids based on what we read.
expect(returned).toContain('data-hf-id="hf-');
// Disk must not be overwritten — user's concurrent save is preserved.
expect(readFileSync(file, "utf-8")).toBe(newer);
});
});
43 changes: 30 additions & 13 deletions packages/core/src/studio-api/helpers/hfIdPersist.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
import { ensureHfIds } from "../../parsers/hfIds.js";
import { writeFileSync } from "node:fs";

export { ensureHfIds };

export function normalizeHfIds(html: string): { html: string; changed: boolean } {
const normalized = ensureHfIds(html);
return { html: normalized, changed: normalized !== html };
}
import { readFileSync, writeFileSync } from "node:fs";

/**
* Ensure `html` has `data-hf-id` attributes minted, and write the result back
* to `filePath` if new ids were added.
*
* **Invariant:** `html` must be the raw file content read from `filePath` just
* before this call. If `html` is constructed or transformed HTML the TOCTOU
* guard (`current === html`) will never match and writes will silently be
* skipped — no ids will reach disk.
*/
export function persistHfIdsIfNeeded(filePath: string, html: string): string {
const { html: normalized, changed } = normalizeHfIds(html);
if (changed) {
const normalized = ensureHfIds(html);
// Use attribute count instead of string equality: linkedom serialization may
// normalize quote style and whitespace even when no ids were actually minted,
// which would cause spurious writes on every request.
const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
if (idsAfter > idsBefore) {
try {
writeFileSync(filePath, normalized, "utf-8");
} catch {
// non-fatal — serve with ids even if persist fails
// Re-read before writing to guard against concurrent user saves. If the
// file changed since we read it, skip the write — serving with ids is
// still correct; the next request will re-persist. Best-effort only: a
// user save landing between readFileSync and writeFileSync below can
// still be overwritten (microsecond window).
const current = readFileSync(filePath, "utf-8");
if (current === html) {
writeFileSync(filePath, normalized, "utf-8");
}
} catch (err) {
// Non-fatal — serve with ids even if the disk write fails (e.g. read-only
// filesystem, sandboxed environment). Log so the failure is diagnosable.
console.warn("[hyperframes] persistHfIdsIfNeeded: failed to write ids to disk:", err);
}
}
return normalized;
Expand Down
43 changes: 39 additions & 4 deletions packages/core/src/studio-api/helpers/previewAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,21 @@ describe("T10 — PreviewAdapter contract (spec for R7)", () => {
expect(adapter.elementAtPoint(0, 0)).toBeNull();
});

it("skips elements whose computed opacity is 0 at the given playhead time", () => {
it("skips elements whose currently-computed opacity is 0 (atTime is a caller-seek hint, not evaluated by the adapter)", () => {
const elem = make("div", { "data-hf-id": "hf-zzzz" }, { opacity: "0" });
const adapter = adapterWith(() => elem);
expect(adapter.elementAtPoint(0, 0, { atTime: 1.0 })).toBeNull();
});

it("returns null for nested data-hf-root without data-hf-id (treated same as outer stage root)", () => {
const outerRoot = make("div", { "data-hf-root": "true" });
const innerRoot = document.createElement("div");
innerRoot.setAttribute("data-hf-root", "true");
// no data-hf-id — no explicit id means no draggable target
outerRoot.appendChild(innerRoot);
const adapter = adapterWith(() => innerRoot);
expect(adapter.elementAtPoint(0, 0)).toBeNull();
});
});

// ── applyDraft / revertDraft ───────────────────────────────────────────
Expand Down Expand Up @@ -130,19 +140,44 @@ describe("T10 — PreviewAdapter contract (spec for R7)", () => {
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("15px");
});

it("resize → move switch clears width/height props — no cross-type prop leak", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "resize", hfId: "hf-aaaa", w: 200, h: 100 });
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 5 });
// move props set
expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("10px");
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("5px");
// resize props cleared by the auto-revert before re-apply
expect(target.style.getPropertyValue("--hf-studio-width")).toBe("");
expect(target.style.getPropertyValue("--hf-studio-height")).toBe("");
});

it("revertDraft after commitPreview is a no-op — does not restore stale translate", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
target.style.setProperty("translate", "50px 0px");
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 0 });
adapter.commitPreview();
// simulate caller applying translate after commit
target.style.setProperty("translate", "10px 0px");
adapter.revertDraft(); // no gesture in flight — should be no-op
expect(target.style.getPropertyValue("translate")).toBe("10px 0px");
});

it("revertDraft is safe to call when no gesture is in progress (idempotent / no-op on empty marker)", () => {
const adapter = adapterWith(() => null);
expect(() => adapter.revertDraft()).not.toThrow();
expect(() => adapter.revertDraft()).not.toThrow();
});

it("elementAtPoint filtering is stable when playhead changes mid-drag — opacity re-evaluated per call", () => {
it("elementAtPoint filtering is stable when inline opacity changes mid-drag — computed style re-evaluated per call", () => {
const elem = make("div", { "data-hf-id": "hf-zzzz" });
const adapter = adapterWith(() => elem);
expect(adapter.elementAtPoint(0, 0, { atTime: 0 })).toBe(elem);
expect(adapter.elementAtPoint(0, 0)).toBe(elem);
// simulates GSAP seeking to a time where the element is hidden
elem.style.setProperty("opacity", "0");
expect(adapter.elementAtPoint(0, 0, { atTime: 1.0 })).toBeNull();
expect(adapter.elementAtPoint(0, 0)).toBeNull();
});

it("stage-root exclusion applies only to the outermost data-hf-root; nested sub-composition roots count as targets", () => {
Expand Down
Loading
Loading