@hyperframes/studio 0.7.28 → 0.7.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/assets/{index-BGUJ2C2G.js → index-BhSyGx7y.js} +1 -1
  2. package/dist/assets/{index-Bz6Eqd_G.js → index-D0yNztV_.js} +1 -1
  3. package/dist/assets/{index-CLlPjdPl.js → index-kbACg3_I.js} +139 -139
  4. package/dist/{chunk-AN2EWWK3.js → chunk-JND3XUJL.js} +38 -10
  5. package/dist/chunk-JND3XUJL.js.map +1 -0
  6. package/dist/{domEditingLayers-EK7R7R4G.js → domEditingLayers-UIQZJCOA.js} +4 -2
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1145 -675
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -7
  12. package/src/components/editor/DomEditOverlay.test.ts +95 -0
  13. package/src/components/editor/DomEditOverlay.tsx +5 -3
  14. package/src/components/editor/domEditOverlayGestures.ts +3 -4
  15. package/src/components/editor/domEditing.ts +1 -0
  16. package/src/components/editor/domEditingLayers.test.ts +52 -0
  17. package/src/components/editor/domEditingLayers.ts +55 -19
  18. package/src/components/editor/domEditingTypes.ts +1 -0
  19. package/src/components/editor/persistSeam.integration.test.ts +264 -0
  20. package/src/components/editor/propertyPanelPrimitives.tsx +15 -1
  21. package/src/components/editor/useDomEditOverlayGestures.ts +1 -0
  22. package/src/hooks/domEditCommitRunner.ts +46 -0
  23. package/src/hooks/domEditPersistFailure.test.ts +123 -0
  24. package/src/hooks/domEditPersistFailure.ts +89 -0
  25. package/src/hooks/domEditTextFieldCommitOps.test.ts +111 -0
  26. package/src/hooks/domEditTextFieldCommitOps.ts +63 -0
  27. package/src/hooks/domSelectionTestHarness.ts +40 -0
  28. package/src/hooks/useDomEditAttributeCommits.ts +227 -0
  29. package/src/hooks/useDomEditCommits.test.tsx +775 -0
  30. package/src/hooks/useDomEditCommits.ts +33 -5
  31. package/src/hooks/useDomEditTextCommits.ts +243 -220
  32. package/src/hooks/useDomSelection.test.ts +134 -0
  33. package/src/hooks/useDomSelection.ts +29 -15
  34. package/src/hooks/usePreviewInteraction.test.ts +260 -0
  35. package/src/hooks/usePreviewInteraction.ts +60 -19
  36. package/src/utils/sdkCutoverEligibility.test.ts +17 -0
  37. package/src/utils/sdkCutoverEligibility.ts +8 -2
  38. package/src/utils/sdkResolverShadow.test.ts +180 -1
  39. package/src/utils/sdkResolverShadow.ts +94 -1
  40. package/src/utils/sourcePatcher.ts +2 -0
  41. package/src/utils/studioPreviewHelpers.test.ts +95 -1
  42. package/src/utils/studioPreviewHelpers.ts +109 -10
  43. package/src/utils/studioSaveDiagnostics.ts +5 -2
  44. package/src/utils/studioTelemetry.ts +27 -20
  45. package/dist/chunk-AN2EWWK3.js.map +0 -1
  46. /package/dist/{domEditingLayers-EK7R7R4G.js.map → domEditingLayers-UIQZJCOA.js.map} +0 -0
@@ -1,10 +1,14 @@
1
- import { describe, expect, it, vi, beforeEach } from "vitest";
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
3
  import {
3
4
  sdkResolverShadowCheck,
4
5
  runResolverShadow,
5
6
  recordResolverParity,
6
7
  recordAnimationResolverParity,
7
8
  evaluateSoakGate,
9
+ recordAttempt,
10
+ flushAttemptCounts,
11
+ __resetAttemptSchedulingForTests,
8
12
  type SdkResolverMismatch,
9
13
  } from "./sdkResolverShadow";
10
14
  import type { PatchOperation } from "./sourcePatcher";
@@ -13,12 +17,15 @@ import { openComposition } from "@hyperframes/sdk";
13
17
  // ─── Telemetry capture ────────────────────────────────────────────────────────
14
18
 
15
19
  const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
20
+ const flushViaBeacon = vi.fn();
16
21
  vi.mock("./studioTelemetry", () => ({
17
22
  trackStudioEvent: (event: string, props: Record<string, unknown>) =>
18
23
  trackedEvents.push({ event, props }),
24
+ flushViaBeacon: () => flushViaBeacon(),
19
25
  }));
20
26
  beforeEach(() => {
21
27
  trackedEvents.length = 0;
28
+ flushViaBeacon.mockClear();
22
29
  });
23
30
  const lastShadow = () =>
24
31
  trackedEvents.filter((e) => e.event === "sdk_resolver_shadow").at(-1)?.props;
@@ -532,3 +539,175 @@ describe("H. inlined sub-composition leaf", () => {
532
539
  expect(mismatches.some((m) => m.kind === "element_not_found")).toBe(false);
533
540
  });
534
541
  });
542
+
543
+ // ─── I. Attempt counter (denominator for the soak gate) ───────────────────────
544
+
545
+ describe("I. recordAttempt / flushAttemptCounts", () => {
546
+ beforeEach(() => {
547
+ // Drain any counts left over from a prior test so each test starts clean.
548
+ flushAttemptCounts();
549
+ });
550
+
551
+ it("flushAttemptCounts returns null when nothing has been recorded", () => {
552
+ expect(flushAttemptCounts()).toBeNull();
553
+ });
554
+
555
+ it("increments the counter for a given op label", () => {
556
+ recordAttempt("setTiming");
557
+ expect(flushAttemptCounts()).toEqual({ setTiming: 1 });
558
+ });
559
+
560
+ it("accumulates multiple calls with the same label", () => {
561
+ recordAttempt("setTiming");
562
+ recordAttempt("setTiming");
563
+ recordAttempt("setTiming");
564
+ expect(flushAttemptCounts()).toEqual({ setTiming: 3 });
565
+ });
566
+
567
+ it("tracks different labels independently", () => {
568
+ recordAttempt("setTiming");
569
+ recordAttempt("addGsapTween");
570
+ recordAttempt("setTiming");
571
+ expect(flushAttemptCounts()).toEqual({ setTiming: 2, addGsapTween: 1 });
572
+ });
573
+
574
+ it("resets to empty after a flush", () => {
575
+ recordAttempt("setTiming");
576
+ flushAttemptCounts();
577
+ expect(flushAttemptCounts()).toBeNull();
578
+ });
579
+ });
580
+
581
+ describe("I. attempt counting inside the three emit functions", () => {
582
+ beforeEach(() => {
583
+ flushAttemptCounts();
584
+ });
585
+
586
+ it("runResolverShadow counts an attempt on the parity (silent) path", async () => {
587
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
588
+ const session = await openComposition(BASE_HTML);
589
+ runResolverShadow(session, "hf-box", [
590
+ { type: "inline-style", property: "color", value: "blue" },
591
+ ]);
592
+ expect(flushAttemptCounts()).toEqual({ "dom-edit": 1 });
593
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
594
+ });
595
+
596
+ it("runResolverShadow counts an attempt on the divergence (emits) path too", async () => {
597
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
598
+ const session = await openComposition(BASE_HTML);
599
+ runResolverShadow(session, "hf-missing", [
600
+ { type: "inline-style", property: "color", value: "blue" },
601
+ ]);
602
+ expect(flushAttemptCounts()).toEqual({ "dom-edit": 1 });
603
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
604
+ });
605
+
606
+ it("recordResolverParity counts an attempt on the parity (silent) path", async () => {
607
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
608
+ const session = await openComposition(BASE_HTML);
609
+ await recordResolverParity(session, "hf-box", "setTiming");
610
+ expect(flushAttemptCounts()).toEqual({ setTiming: 1 });
611
+ });
612
+
613
+ it("recordResolverParity counts an attempt on the divergence (emits) path too", async () => {
614
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
615
+ const session = await openComposition(BASE_HTML);
616
+ await recordResolverParity(session, "hf-missing", "setTiming");
617
+ expect(flushAttemptCounts()).toEqual({ setTiming: 1 });
618
+ });
619
+
620
+ it("recordAnimationResolverParity counts an attempt on the parity (silent) path", async () => {
621
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
622
+ const session = await openComposition(GSAP_HTML);
623
+ const realId = session.getElements().flatMap((e) => [...e.animationIds])[0] ?? "";
624
+ expect(realId).not.toBe(""); // fixture has a tween on hf-box, see block G above
625
+ recordAnimationResolverParity(session, realId, "removeGsapTween");
626
+ expect(flushAttemptCounts()).toEqual({ removeGsapTween: 1 });
627
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
628
+ });
629
+
630
+ it("recordAnimationResolverParity counts an attempt on the divergence (emits) path too", async () => {
631
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
632
+ const session = await openComposition(GSAP_HTML);
633
+ recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
634
+ expect(flushAttemptCounts()).toEqual({ setGsapTween: 1 });
635
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
636
+ });
637
+
638
+ it("counts accumulate across multiple different chokepoints in one rollup", async () => {
639
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
640
+ const session = await openComposition(BASE_HTML);
641
+ await recordResolverParity(session, "hf-box", "setTiming");
642
+ await recordResolverParity(session, "hf-box", "setTiming");
643
+ recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
644
+ expect(flushAttemptCounts()).toEqual({ setTiming: 2, setGsapTween: 1 });
645
+ });
646
+
647
+ it("does not count an attempt when the flag is off", async () => {
648
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
649
+ const session = await openComposition(BASE_HTML);
650
+ runResolverShadow(session, "hf-box", [
651
+ { type: "inline-style", property: "color", value: "blue" },
652
+ ]);
653
+ await recordResolverParity(session, "hf-box", "setTiming");
654
+ recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
655
+ expect(flushAttemptCounts()).toBeNull();
656
+ });
657
+ });
658
+
659
+ describe("I. production rollup wiring", () => {
660
+ beforeEach(() => {
661
+ flushAttemptCounts();
662
+ __resetAttemptSchedulingForTests();
663
+ vi.useFakeTimers({ toFake: ["setTimeout", "setInterval", "clearInterval", "clearTimeout"] });
664
+ });
665
+
666
+ afterEach(() => {
667
+ __resetAttemptSchedulingForTests();
668
+ vi.useRealTimers();
669
+ Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
670
+ });
671
+
672
+ it("does not emit a rollup event when nothing was recorded", () => {
673
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
674
+ vi.advanceTimersByTime(5 * 60_000);
675
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt")).toHaveLength(0);
676
+ });
677
+
678
+ it("emits a sdk_resolver_shadow_attempt rollup event every 5 minutes after the first attempt", async () => {
679
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
680
+ const session = await openComposition(BASE_HTML);
681
+ await recordResolverParity(session, "hf-box", "setTiming");
682
+ vi.advanceTimersByTime(5 * 60_000);
683
+ const rollups = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt");
684
+ expect(rollups).toHaveLength(1);
685
+ expect(rollups[0].props.counts).toBe(JSON.stringify({ setTiming: 1 }));
686
+ });
687
+
688
+ it("flushes a rollup and forces a beacon delivery on visibilitychange -> hidden", async () => {
689
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
690
+ const session = await openComposition(BASE_HTML);
691
+ await recordResolverParity(session, "hf-box", "setTiming");
692
+ Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
693
+ document.dispatchEvent(new Event("visibilitychange"));
694
+ const rollups = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt");
695
+ expect(rollups).toHaveLength(1);
696
+ expect(rollups[0].props.counts).toBe(JSON.stringify({ setTiming: 1 }));
697
+ // Delivery must not depend on studioTelemetry's own visibilitychange listener
698
+ // winning a race — this module forces its own beacon flush.
699
+ expect(flushViaBeacon).toHaveBeenCalledTimes(1);
700
+ });
701
+
702
+ it("does not register a duplicate visibilitychange listener after a scheduling reset", async () => {
703
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
704
+ const session = await openComposition(BASE_HTML);
705
+ await recordResolverParity(session, "hf-box", "setTiming");
706
+ __resetAttemptSchedulingForTests();
707
+ await recordResolverParity(session, "hf-box", "setTiming"); // re-arms scheduling, incl. listener
708
+ Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
709
+ document.dispatchEvent(new Event("visibilitychange"));
710
+ // If the reset had leaked the old listener, this would fire twice.
711
+ expect(flushViaBeacon).toHaveBeenCalledTimes(1);
712
+ });
713
+ });
@@ -19,7 +19,7 @@ import type { Composition, JsonPatchOp } from "@hyperframes/sdk";
19
19
  import type { PatchOperation } from "./sourcePatcher";
20
20
  import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
21
21
  import { patchOpsToSdkEditOps } from "./sdkOpMapping";
22
- import { trackStudioEvent } from "./studioTelemetry";
22
+ import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry";
23
23
 
24
24
  // ─── Types ────────────────────────────────────────────────────────────────────
25
25
 
@@ -229,6 +229,96 @@ export function sdkResolverShadowCheck(
229
229
  }
230
230
  }
231
231
 
232
+ // ─── Attempt counter (denominator for the soak gate) ──────────────────────────
233
+ //
234
+ // The three emit functions below only fire a PostHog event on divergence —
235
+ // parity is silent, by design, to avoid firing on every edit. That leaves no
236
+ // way to compute a rate (divergences / attempts): we can count failures but
237
+ // never attempts. This counter tracks attempts in memory and rolls them up
238
+ // into ONE low-frequency event instead of firing per-attempt, which would
239
+ // recreate the exact chattiness problem the divergence-only design avoids.
240
+
241
+ const attemptCounts: Record<string, number> = {};
242
+
243
+ /**
244
+ * Record that the resolver-shadow tripwire ran for `opLabel`, regardless of
245
+ * outcome (parity or divergence). No flag check of its own — only ever called
246
+ * from inside the three emit functions below, after their own
247
+ * STUDIO_SDK_RESOLVER_SHADOW_ENABLED guard, so it's already flag-gated.
248
+ */
249
+ export function recordAttempt(opLabel: string): void {
250
+ attemptCounts[opLabel] = (attemptCounts[opLabel] ?? 0) + 1;
251
+ ensureAttemptFlushScheduled();
252
+ }
253
+
254
+ /**
255
+ * Return the accumulated attempt counts since the last flush (or `null` if
256
+ * nothing has been recorded — no point emitting an empty rollup), and reset
257
+ * the counter to empty.
258
+ */
259
+ export function flushAttemptCounts(): Record<string, number> | null {
260
+ const keys = Object.keys(attemptCounts);
261
+ if (keys.length === 0) return null;
262
+ const snapshot: Record<string, number> = {};
263
+ for (const key of keys) {
264
+ snapshot[key] = attemptCounts[key];
265
+ delete attemptCounts[key];
266
+ }
267
+ return snapshot;
268
+ }
269
+
270
+ const ATTEMPT_FLUSH_INTERVAL_MS = 5 * 60_000;
271
+ let attemptFlushTimer: ReturnType<typeof setInterval> | null = null;
272
+ let attemptVisibilityHandler: (() => void) | null = null;
273
+
274
+ function flushAndEmitAttempts(): void {
275
+ const counts = flushAttemptCounts();
276
+ if (counts === null) return;
277
+ trackStudioEvent("sdk_resolver_shadow_attempt", { counts: JSON.stringify(counts) });
278
+ }
279
+
280
+ // Lazily starts the rollup timer + visibilitychange listener on the FIRST
281
+ // attempt in a session — mirrors studioTelemetry.ts's own lazy flushTimer
282
+ // start, so a session that never exercises the tripwire never runs a
283
+ // background timer.
284
+ function ensureAttemptFlushScheduled(): void {
285
+ if (!attemptFlushTimer) {
286
+ attemptFlushTimer = setInterval(flushAndEmitAttempts, ATTEMPT_FLUSH_INTERVAL_MS);
287
+ }
288
+ if (!attemptVisibilityHandler && typeof document !== "undefined") {
289
+ attemptVisibilityHandler = () => {
290
+ if (document.visibilityState !== "hidden") return;
291
+ flushAndEmitAttempts();
292
+ // studioTelemetry.ts registers its own visibilitychange listener (on
293
+ // window, at module load) that drains its queue via sendBeacon. Listener
294
+ // execution order between that handler and this one (on document,
295
+ // registered lazily) is not something to rely on — whichever runs
296
+ // first could otherwise beacon-flush before or after this rollup lands
297
+ // in the queue. Forcing a beacon flush here makes delivery of this
298
+ // rollup event correct regardless of that order.
299
+ flushViaBeacon();
300
+ };
301
+ document.addEventListener("visibilitychange", attemptVisibilityHandler);
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Test-only: clears the lazy timer/listener singleton state so tests can
307
+ * verify the "starts on first attempt" behavior in isolation, without an
308
+ * earlier test's real-timer interval (or visibilitychange listener) silently
309
+ * surviving into a later test. Does NOT touch attemptCounts — only the
310
+ * scheduling state. Not part of the public module contract; only imported
311
+ * from sdkResolverShadow.test.ts.
312
+ */
313
+ export function __resetAttemptSchedulingForTests(): void {
314
+ if (attemptFlushTimer) clearInterval(attemptFlushTimer);
315
+ attemptFlushTimer = null;
316
+ if (attemptVisibilityHandler && typeof document !== "undefined") {
317
+ document.removeEventListener("visibilitychange", attemptVisibilityHandler);
318
+ }
319
+ attemptVisibilityHandler = null;
320
+ }
321
+
232
322
  // ─── Telemetry ────────────────────────────────────────────────────────────────
233
323
 
234
324
  // Redact all user-content values before telemetry: style values and text both
@@ -266,6 +356,7 @@ export function runResolverShadow(
266
356
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
267
357
  if (!hfId) return;
268
358
  try {
359
+ recordAttempt("dom-edit");
269
360
  const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
270
361
  // Emit only on divergence — parity is silent, matching recordResolverParity
271
362
  // and recordAnimationResolverParity. Otherwise this fires a PostHog event on
@@ -318,6 +409,7 @@ export async function recordResolverParity(
318
409
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
319
410
  if (!session || !hfId) return;
320
411
  try {
412
+ recordAttempt(opLabel);
321
413
  if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
322
414
  // Capture BEFORE any await: this call is fire-and-forget (`void recordResolverParity(...)`)
323
415
  // and the caller runs its own session mutation synchronously right after this call
@@ -380,6 +472,7 @@ export function recordAnimationResolverParity(
380
472
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
381
473
  if (!session || !animationId) return;
382
474
  try {
475
+ recordAttempt(opLabel);
383
476
  const elements = session.getElements();
384
477
  const resolves = elements.some((el) => el.animationIds.includes(animationId));
385
478
  if (resolves) return; // SDK locates the animation — parity
@@ -90,6 +90,8 @@ export interface PatchOperation {
90
90
  type: "inline-style" | "attribute" | "text-content" | "html-attribute";
91
91
  property: string;
92
92
  value: string | null;
93
+ childSelector?: string;
94
+ childIndex?: number;
93
95
  }
94
96
 
95
97
  // Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
@@ -1,5 +1,29 @@
1
+ // @vitest-environment happy-dom
2
+
1
3
  import { describe, expect, it, vi } from "vitest";
2
- import { coversComposition, pauseStudioPreviewPlayback } from "./studioPreviewHelpers";
4
+ import {
5
+ coversComposition,
6
+ getPreviewTargetFromPointer,
7
+ pauseStudioPreviewPlayback,
8
+ } from "./studioPreviewHelpers";
9
+
10
+ function domRect(left: number, top: number, width: number, height: number): DOMRect {
11
+ return {
12
+ x: left,
13
+ y: top,
14
+ left,
15
+ top,
16
+ width,
17
+ height,
18
+ right: left + width,
19
+ bottom: top + height,
20
+ toJSON: () => ({}),
21
+ };
22
+ }
23
+
24
+ function stubRect(el: Element, rect: DOMRect): void {
25
+ el.getBoundingClientRect = () => rect;
26
+ }
3
27
 
4
28
  describe("coversComposition (full-bleed canvas-pick exclusion)", () => {
5
29
  const viewport = { width: 1920, height: 1080 };
@@ -79,3 +103,73 @@ describe("pauseStudioPreviewPlayback", () => {
79
103
  expect(siblingPause).toHaveBeenCalledTimes(1);
80
104
  });
81
105
  });
106
+
107
+ describe("getPreviewTargetFromPointer", () => {
108
+ it("skips candidates hidden from author hit-testing by inherited pointer-events:none", () => {
109
+ const iframe = document.createElement("iframe");
110
+ document.body.append(iframe);
111
+ const doc = iframe.contentDocument;
112
+ if (!doc) throw new Error("Expected iframe document");
113
+
114
+ doc.body.innerHTML = `
115
+ <main id="scene" data-composition-id="scene">
116
+ <h1 id="headline">Launch title</h1>
117
+ <div id="overlay-parent" style="pointer-events: none;">
118
+ <div id="overlay" style="position: absolute; background: rgba(0, 0, 0, 0.1);"></div>
119
+ </div>
120
+ </main>
121
+ `;
122
+
123
+ const scene = doc.getElementById("scene");
124
+ const headline = doc.getElementById("headline");
125
+ const overlayParent = doc.getElementById("overlay-parent");
126
+ const overlay = doc.getElementById("overlay");
127
+ if (!scene || !headline || !overlayParent || !overlay) {
128
+ throw new Error("Expected preview fixture elements");
129
+ }
130
+
131
+ stubRect(iframe, domRect(0, 0, 400, 300));
132
+ stubRect(scene, domRect(0, 0, 400, 300));
133
+ stubRect(headline, domRect(40, 40, 160, 48));
134
+ stubRect(overlayParent, domRect(0, 0, 360, 260));
135
+ stubRect(overlay, domRect(0, 0, 360, 260));
136
+ doc.elementsFromPoint = () => [overlay, overlayParent, headline, scene];
137
+
138
+ expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
139
+
140
+ iframe.remove();
141
+ });
142
+
143
+ it("honors a CSS-class pointer-events:auto opt-in under a pointer-events:none ancestor", () => {
144
+ const iframe = document.createElement("iframe");
145
+ document.body.append(iframe);
146
+ const doc = iframe.contentDocument;
147
+ if (!doc) throw new Error("Expected iframe document");
148
+
149
+ doc.head.innerHTML = `<style>.clickable { pointer-events: auto; }</style>`;
150
+ doc.body.innerHTML = `
151
+ <main id="scene" data-composition-id="scene">
152
+ <div id="overlay-parent" style="pointer-events: none;">
153
+ <button id="clickable-child" class="clickable">Play</button>
154
+ </div>
155
+ </main>
156
+ `;
157
+
158
+ const scene = doc.getElementById("scene");
159
+ const overlayParent = doc.getElementById("overlay-parent");
160
+ const clickableChild = doc.getElementById("clickable-child");
161
+ if (!scene || !overlayParent || !clickableChild) {
162
+ throw new Error("Expected preview fixture elements");
163
+ }
164
+
165
+ stubRect(iframe, domRect(0, 0, 400, 300));
166
+ stubRect(scene, domRect(0, 0, 400, 300));
167
+ stubRect(overlayParent, domRect(0, 0, 360, 260));
168
+ stubRect(clickableChild, domRect(40, 40, 80, 24));
169
+ doc.elementsFromPoint = () => [clickableChild, overlayParent, scene];
170
+
171
+ expect(getPreviewTargetFromPointer(iframe, 60, 50, "index.html")).toBe(clickableChild);
172
+
173
+ iframe.remove();
174
+ });
175
+ });
@@ -4,6 +4,7 @@ import {
4
4
  isElementComputedVisible,
5
5
  resolveAllVisualDomEditTargets,
6
6
  } from "../components/editor/domEditingElement";
7
+ import { isHtmlElement } from "../components/editor/domEditingDom";
7
8
  import { getEventTargetElement } from "./studioHelpers";
8
9
 
9
10
  interface PreviewLocalPointer {
@@ -81,11 +82,90 @@ function removePointerEventsOverride(style: HTMLStyleElement | null): void {
81
82
  }
82
83
  }
83
84
 
85
+ const pointerEventsInheritanceFallbackByDocument = new WeakMap<Document, boolean>();
86
+
87
+ function needsPointerEventsInheritanceFallback(doc: Document, win: Window): boolean {
88
+ const cached = pointerEventsInheritanceFallbackByDocument.get(doc);
89
+ if (cached !== undefined) return cached;
90
+
91
+ const parent = doc.createElement("div");
92
+ const child = doc.createElement("div");
93
+ parent.style.pointerEvents = "none";
94
+ parent.appendChild(child);
95
+ const host = doc.body ?? doc.documentElement;
96
+ if (!host) return false;
97
+
98
+ host.appendChild(parent);
99
+ const needsFallback = win.getComputedStyle(child).pointerEvents !== "none";
100
+ parent.remove();
101
+ pointerEventsInheritanceFallbackByDocument.set(doc, needsFallback);
102
+ return needsFallback;
103
+ }
104
+
105
+ // Own declared pointer-events value, via computed style rather than inline
106
+ // style, so a CSS-class opt-in/opt-out (not just an inline style attribute)
107
+ // is honored when walking back down from a pointer-events:none ancestor.
108
+ function hasOwnPointerEventsOverride(el: HTMLElement, win: Window): boolean {
109
+ const value = win.getComputedStyle(el).pointerEvents;
110
+ return value !== "" && value !== "inherit" && value !== "unset";
111
+ }
112
+
113
+ function inheritsPointerEventsNoneFromAncestor(el: HTMLElement, win: Window): boolean {
114
+ let current = el.parentElement;
115
+ while (current) {
116
+ if (win.getComputedStyle(current).pointerEvents === "none") {
117
+ let descendant: HTMLElement | null = el;
118
+ while (descendant && descendant !== current) {
119
+ if (hasOwnPointerEventsOverride(descendant, win)) {
120
+ return win.getComputedStyle(descendant).pointerEvents === "none";
121
+ }
122
+ descendant = descendant.parentElement;
123
+ }
124
+ return true;
125
+ }
126
+ current = current.parentElement;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ function hasAuthorPointerEventsNone(el: HTMLElement): boolean {
132
+ const win = el.ownerDocument.defaultView;
133
+ if (!win) return false;
134
+ if (win.getComputedStyle(el).pointerEvents === "none") return true;
135
+ if (!needsPointerEventsInheritanceFallback(el.ownerDocument, win)) return false;
136
+ return inheritsPointerEventsNoneFromAncestor(el, win);
137
+ }
138
+
139
+ function collectPointerEventsNoneTargets(
140
+ elements: Iterable<Element | null | undefined>,
141
+ ): WeakSet<HTMLElement> {
142
+ const disabled = new WeakSet<HTMLElement>();
143
+ for (const entry of elements) {
144
+ if (isHtmlElement(entry) && hasAuthorPointerEventsNone(entry)) {
145
+ disabled.add(entry);
146
+ }
147
+ }
148
+ return disabled;
149
+ }
150
+
151
+ // Shared tail of both pointer resolvers: hit-test candidates minus elements the
152
+ // author hid from hit-testing via pointer-events:none.
153
+ function filterAuthorInteractiveTargets(
154
+ elements: Element[],
155
+ activeCompositionPath: string | null,
156
+ ): HTMLElement[] {
157
+ const pointerEventsNoneTargets = collectPointerEventsNoneTargets(elements);
158
+ return resolveAllVisualDomEditTargets(elements, { activeCompositionPath }).filter(
159
+ (el) => !pointerEventsNoneTargets.has(el),
160
+ );
161
+ }
162
+
84
163
  // Animated group members can move outside their wrapper's static layout box, so
85
164
  // the empty space inside a group's *visual* bounds (the member-union the overlay
86
165
  // draws) doesn't hit-test to the group via elementsFromPoint. Recover it: if the
87
166
  // point falls within a group's live member-union rect, return that wrapper.
88
167
  // Innermost (smallest-area) group wins for nested groups.
168
+ // fallow-ignore-next-line complexity
89
169
  function findGroupAtPoint(doc: Document, x: number, y: number): HTMLElement | null {
90
170
  let best: HTMLElement | null = null;
91
171
  let bestArea = Infinity;
@@ -132,26 +212,39 @@ export function getPreviewTargetFromPointer(
132
212
  const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
133
213
  if (!localPointer) return null;
134
214
 
135
- const overrideStyle = forcePointerEventsAuto(doc);
215
+ let overrideStyle = forcePointerEventsAuto(doc);
136
216
  try {
137
217
  if (typeof doc.elementsFromPoint === "function") {
138
- const candidates = resolveAllVisualDomEditTargets(
139
- doc.elementsFromPoint(localPointer.x, localPointer.y),
140
- { activeCompositionPath },
141
- );
218
+ const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
219
+ removePointerEventsOverride(overrideStyle);
220
+ overrideStyle = null;
221
+ const candidates = filterAuthorInteractiveTargets(elements, activeCompositionPath);
142
222
  const visualTarget =
143
223
  candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null;
144
224
  if (visualTarget) return visualTarget;
145
225
  }
146
226
 
227
+ // Belt-and-suspenders: elementsFromPoint is universally supported in the
228
+ // browsers this ships in, so the override is already removed by this
229
+ // point in practice — but guard the environment without it too, so
230
+ // hasAuthorPointerEventsNone below never reads a forced-auto value.
231
+ removePointerEventsOverride(overrideStyle);
232
+ overrideStyle = null;
233
+
147
234
  // No element hit (e.g. empty space inside an animated group's overlay) — fall
148
235
  // back to the group whose member-union contains the point, so the whole group
149
236
  // area is hoverable/selectable, not just where a member currently sits.
150
237
  const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
151
- if (groupHit && getDomLayerPatchTarget(groupHit, activeCompositionPath)) return groupHit;
238
+ if (
239
+ groupHit &&
240
+ !hasAuthorPointerEventsNone(groupHit) &&
241
+ getDomLayerPatchTarget(groupHit, activeCompositionPath)
242
+ )
243
+ return groupHit;
152
244
 
153
245
  const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
154
246
  if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
247
+ if (hasAuthorPointerEventsNone(fallback)) return null;
155
248
  if (!isElementComputedVisible(fallback)) return null;
156
249
  if (isFullBleedTarget(fallback, localPointer.viewport)) return null;
157
250
  return fallback;
@@ -180,15 +273,21 @@ export function getAllPreviewTargetsFromPointer(
180
273
  const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
181
274
  if (!localPointer) return [];
182
275
 
183
- const overrideStyle = forcePointerEventsAuto(doc);
276
+ let overrideStyle = forcePointerEventsAuto(doc);
184
277
  try {
185
278
  if (typeof doc.elementsFromPoint === "function") {
186
- return resolveAllVisualDomEditTargets(doc.elementsFromPoint(localPointer.x, localPointer.y), {
187
- activeCompositionPath,
188
- }).filter((el) => !isFullBleedTarget(el, localPointer.viewport));
279
+ const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
280
+ removePointerEventsOverride(overrideStyle);
281
+ overrideStyle = null;
282
+ return filterAuthorInteractiveTargets(elements, activeCompositionPath).filter(
283
+ (el) => !isFullBleedTarget(el, localPointer.viewport),
284
+ );
189
285
  }
190
286
  const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
191
287
  if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return [];
288
+ removePointerEventsOverride(overrideStyle);
289
+ overrideStyle = null;
290
+ if (hasAuthorPointerEventsNone(fallback)) return [];
192
291
  if (!isElementComputedVisible(fallback)) return [];
193
292
  if (isFullBleedTarget(fallback, localPointer.viewport)) return [];
194
293
  return [fallback];
@@ -18,11 +18,13 @@ export interface StudioSaveFailureInput {
18
18
 
19
19
  export class StudioSaveHttpError extends Error {
20
20
  readonly statusCode: number;
21
+ readonly alreadyToasted: boolean;
21
22
 
22
- constructor(message: string, statusCode: number) {
23
+ constructor(message: string, statusCode: number, options: { alreadyToasted?: boolean } = {}) {
23
24
  super(message);
24
25
  this.name = "StudioSaveHttpError";
25
26
  this.statusCode = statusCode;
27
+ this.alreadyToasted = options.alreadyToasted ?? false;
26
28
  }
27
29
  }
28
30
 
@@ -130,6 +132,7 @@ export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
130
132
  export async function createStudioSaveHttpError(
131
133
  response: Response,
132
134
  fallbackMessage: string,
135
+ options: { alreadyToasted?: boolean } = {},
133
136
  ): Promise<StudioSaveHttpError> {
134
137
  let body = "";
135
138
  try {
@@ -141,7 +144,7 @@ export async function createStudioSaveHttpError(
141
144
  const message = detail
142
145
  ? `${fallbackMessage} (${response.status}): ${detail}`
143
146
  : `${fallbackMessage} (${response.status})`;
144
- return new StudioSaveHttpError(message, response.status);
147
+ return new StudioSaveHttpError(message, response.status, options);
145
148
  }
146
149
 
147
150
  export async function retryStudioSave<T>(