@hyperframes/studio 0.6.114 → 0.6.116

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.
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-DApfviyW.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DS7Kfzvb.css">
8
+ <script type="module" crossorigin src="/assets/index-iuaaor_Q.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BvEhf7SS.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.114",
3
+ "version": "0.6.116",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,9 +36,9 @@
36
36
  "dompurify": "^3.2.4",
37
37
  "marked": "^14.1.4",
38
38
  "mediabunny": "^1.45.3",
39
- "@hyperframes/core": "0.6.114",
40
- "@hyperframes/player": "0.6.114",
41
- "@hyperframes/sdk": "0.6.114"
39
+ "@hyperframes/core": "0.6.116",
40
+ "@hyperframes/sdk": "0.6.116",
41
+ "@hyperframes/player": "0.6.116"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/react": "19",
@@ -52,7 +52,7 @@
52
52
  "vite": "^6.4.2",
53
53
  "vitest": "^3.2.4",
54
54
  "zustand": "^5.0.0",
55
- "@hyperframes/producer": "0.6.114"
55
+ "@hyperframes/producer": "0.6.116"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "react": "19",
@@ -126,7 +126,7 @@ export const LayersPanel = memo(function LayersPanel() {
126
126
  if (doc) {
127
127
  const found =
128
128
  (layer.id ? doc.getElementById(layer.id) : null) ??
129
- (layer.hfId ? doc.querySelector(`[data-hf-id="${layer.hfId}"]`) : null) ??
129
+ (layer.hfId ? doc.querySelector(`[data-hf-id="${CSS.escape(layer.hfId)}"]`) : null) ??
130
130
  doc.getElementById(layer.key);
131
131
  if (found instanceof HTMLElement) el = found;
132
132
  }
@@ -242,7 +242,7 @@ export function findElementForSelection(
242
242
  activeCompositionPath: string | null = null,
243
243
  ): HTMLElement | null {
244
244
  if (selection.hfId) {
245
- const byHfId = doc.querySelector(`[data-hf-id="${selection.hfId}"]`);
245
+ const byHfId = doc.querySelector(`[data-hf-id="${CSS.escape(selection.hfId)}"]`);
246
246
  if (isHtmlElement(byHfId)) return byHfId;
247
247
  }
248
248
 
@@ -189,7 +189,7 @@ export const NLELayout = memo(function NLELayout({
189
189
  const doc = iframeRef_.current?.contentDocument;
190
190
  if (doc) {
191
191
  const host = doc.querySelector(
192
- `[data-composition-id="${compId}"][data-composition-src]`,
192
+ `[data-composition-id="${CSS.escape(compId)}"][data-composition-src]`,
193
193
  );
194
194
  if (host) {
195
195
  resolvedPath = host.getAttribute("data-composition-src") || undefined;
@@ -57,7 +57,6 @@ export function safeParseManifest(html: string): SlideshowManifest {
57
57
  import {
58
58
  toggleMainLineSlide,
59
59
  reorderMainLineSlide,
60
- reorderBranchSlide,
61
60
  setSlideNotes,
62
61
  addFragment,
63
62
  removeFragment,
@@ -94,19 +93,24 @@ export function makeSlideshowNotesController(): NotesController {
94
93
  let pending: Pending | null = null;
95
94
  let timer: ReturnType<typeof setTimeout> | null = null;
96
95
 
96
+ // Atomically swap the pending entry out and fire its persist (if any).
97
+ // Used by both the debounce timer's tail and the explicit flush() path.
98
+ const drainPending = (): void => {
99
+ const p = pending;
100
+ if (p === null) return;
101
+ pending = null;
102
+ p.persist(p.manifest).catch((err: unknown) => {
103
+ console.error("[slideshow] notes persist failed:", err);
104
+ });
105
+ };
106
+
97
107
  return {
98
108
  schedule(manifest, persist, delayMs) {
99
109
  if (timer !== null) clearTimeout(timer);
100
110
  pending = { manifest, persist };
101
111
  timer = setTimeout(() => {
102
112
  timer = null;
103
- const p = pending;
104
- if (p !== null) {
105
- pending = null;
106
- p.persist(p.manifest).catch((err: unknown) => {
107
- console.error("[slideshow] notes persist failed:", err);
108
- });
109
- }
113
+ drainPending();
110
114
  }, delayMs);
111
115
  return timer;
112
116
  },
@@ -116,13 +120,7 @@ export function makeSlideshowNotesController(): NotesController {
116
120
  clearTimeout(timer);
117
121
  timer = null;
118
122
  }
119
- const p = pending;
120
- if (p !== null) {
121
- pending = null;
122
- p.persist(p.manifest).catch((err: unknown) => {
123
- console.error("[slideshow] notes persist failed:", err);
124
- });
125
- }
123
+ drainPending();
126
124
  },
127
125
 
128
126
  cancel() {
@@ -293,15 +291,6 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
293
291
  [applyManifest],
294
292
  );
295
293
 
296
- const handleReorderBranchSlide = useCallback(
297
- (sequenceId: string, sceneId: string, dir: "up" | "down") => {
298
- applyManifest(reorderBranchSlide(manifestRef.current, sequenceId, sceneId, dir)).catch(
299
- () => {},
300
- );
301
- },
302
- [applyManifest],
303
- );
304
-
305
294
  const handleSetNotes = useCallback(
306
295
  (notes: string) => {
307
296
  if (!selectedSceneId) return;
@@ -459,7 +448,6 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
459
448
  selectedSceneId={selectedSceneId}
460
449
  selectedSequenceId={selectedSequenceId}
461
450
  onSelectBranchSlide={handleSelectBranchSlide}
462
- onReorderBranchSlide={handleReorderBranchSlide}
463
451
  />
464
452
  )}
465
453
 
@@ -216,7 +216,6 @@ export interface BranchTreeProps {
216
216
  selectedSceneId: string | null;
217
217
  selectedSequenceId: string | null;
218
218
  onSelectBranchSlide: (sequenceId: string, sceneId: string) => void;
219
- onReorderBranchSlide: (sequenceId: string, sceneId: string, dir: "up" | "down") => void;
220
219
  }
221
220
 
222
221
  export function BranchTree({
@@ -229,7 +228,6 @@ export function BranchTree({
229
228
  selectedSceneId,
230
229
  selectedSequenceId,
231
230
  onSelectBranchSlide,
232
- onReorderBranchSlide,
233
231
  }: BranchTreeProps) {
234
232
  const [newLabel, setNewLabel] = useState("");
235
233
  const inputId = useId();
@@ -280,7 +278,6 @@ export function BranchTree({
280
278
  selectedSceneId={selectedSceneId}
281
279
  selectedSequenceId={selectedSequenceId}
282
280
  onSelectBranchSlide={onSelectBranchSlide}
283
- onReorderBranchSlide={onReorderBranchSlide}
284
281
  />
285
282
  ))}
286
283
  </div>
@@ -298,7 +295,6 @@ interface BranchItemProps {
298
295
  selectedSceneId: string | null;
299
296
  selectedSequenceId: string | null;
300
297
  onSelectBranchSlide: (sequenceId: string, sceneId: string) => void;
301
- onReorderBranchSlide: (sequenceId: string, sceneId: string, dir: "up" | "down") => void;
302
298
  }
303
299
 
304
300
  function BranchItem({
@@ -310,7 +306,6 @@ function BranchItem({
310
306
  selectedSceneId,
311
307
  selectedSequenceId,
312
308
  onSelectBranchSlide,
313
- onReorderBranchSlide,
314
309
  }: BranchItemProps) {
315
310
  const [editing, setEditing] = useState(false);
316
311
  const [draft, setDraft] = useState(seq.label);
@@ -362,8 +357,7 @@ function BranchItem({
362
357
  </div>
363
358
  <div className="flex flex-col gap-px pl-2">
364
359
  {scenes.map((scene) => {
365
- const branchPos = seq.slides.findIndex((s) => s.sceneId === scene.id);
366
- const assigned = branchPos !== -1;
360
+ const assigned = seq.slides.some((s) => s.sceneId === scene.id);
367
361
  const isSelected = selectedSequenceId === seq.id && selectedSceneId === scene.id;
368
362
  return (
369
363
  <div
@@ -378,39 +372,16 @@ function BranchItem({
378
372
  className="accent-studio-accent flex-shrink-0"
379
373
  />
380
374
  {assigned ? (
381
- <>
382
- <button
383
- type="button"
384
- aria-pressed={isSelected}
385
- className={`flex-1 text-left truncate transition-colors hover:text-neutral-200 ${
386
- isSelected ? "text-white" : "text-neutral-400"
387
- }`}
388
- onClick={() => onSelectBranchSlide(seq.id, scene.id)}
389
- >
390
- {scene.label || scene.id}
391
- </button>
392
- <span className="text-[9px] text-neutral-600 tabular-nums flex-shrink-0">
393
- {branchPos + 1}/{seq.slides.length}
394
- </span>
395
- <button
396
- type="button"
397
- aria-label={`Move ${scene.label || scene.id} earlier in branch ${seq.label}`}
398
- disabled={branchPos <= 0}
399
- className="text-[10px] text-neutral-500 hover:text-neutral-200 disabled:opacity-30 disabled:cursor-default px-0.5"
400
- onClick={() => onReorderBranchSlide(seq.id, scene.id, "up")}
401
- >
402
-
403
- </button>
404
- <button
405
- type="button"
406
- aria-label={`Move ${scene.label || scene.id} later in branch ${seq.label}`}
407
- disabled={branchPos >= seq.slides.length - 1}
408
- className="text-[10px] text-neutral-500 hover:text-neutral-200 disabled:opacity-30 disabled:cursor-default px-0.5"
409
- onClick={() => onReorderBranchSlide(seq.id, scene.id, "down")}
410
- >
411
-
412
- </button>
413
- </>
375
+ <button
376
+ type="button"
377
+ aria-pressed={isSelected}
378
+ className={`flex-1 text-left truncate transition-colors hover:text-neutral-200 ${
379
+ isSelected ? "text-white" : "text-neutral-400"
380
+ }`}
381
+ onClick={() => onSelectBranchSlide(seq.id, scene.id)}
382
+ >
383
+ {scene.label || scene.id}
384
+ </button>
414
385
  ) : (
415
386
  <span className="flex-1 truncate">{scene.label || scene.id}</span>
416
387
  )}
@@ -123,7 +123,8 @@ export function createTimelineElementFromManifestClip(params: {
123
123
  if (clip.kind === "composition" && clip.compositionId) {
124
124
  let resolvedSrc = clip.compositionSrc;
125
125
  if (!resolvedSrc) {
126
- hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
126
+ hostEl =
127
+ doc?.querySelector(`[data-composition-id="${CSS.escape(clip.compositionId)}"]`) ?? hostEl;
127
128
  resolvedSrc =
128
129
  hostEl?.getAttribute("data-composition-src") ??
129
130
  hostEl?.getAttribute("data-composition-file") ??
@@ -163,13 +163,13 @@ export function getImplicitTimelineLayerLabel(el: HTMLElement): string {
163
163
  // ---------------------------------------------------------------------------
164
164
 
165
165
  export function getTimelineElementSelector(el: Element): string | undefined {
166
- if (isHtmlElement(el) && el.id) return `#${el.id}`;
166
+ if (isHtmlElement(el) && el.id) return `#${CSS.escape(el.id)}`;
167
167
  const compId = el.getAttribute("data-composition-id");
168
- if (compId) return `[data-composition-id="${compId}"]`;
168
+ if (compId) return `[data-composition-id="${CSS.escape(compId)}"]`;
169
169
  if (isHtmlElement(el)) {
170
170
  const classes = el.className.split(/\s+/).filter(Boolean);
171
171
  const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
172
- if (firstClass) return `.${firstClass}`;
172
+ if (firstClass) return `.${CSS.escape(firstClass)}`;
173
173
  }
174
174
  return undefined;
175
175
  }
@@ -283,8 +283,8 @@ function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean
283
283
  function findTimelineDomNode(doc: Document, id: string): Element | null {
284
284
  return (
285
285
  doc.getElementById(id) ??
286
- doc.querySelector(`[data-composition-id="${id}"]`) ??
287
- doc.querySelector(`.${id}`) ??
286
+ doc.querySelector(`[data-composition-id="${CSS.escape(id)}"]`) ??
287
+ doc.querySelector(`.${CSS.escape(id)}`) ??
288
288
  null
289
289
  );
290
290
  }
@@ -295,7 +295,8 @@ export function buildMissingCompositionElements(
295
295
  let start = parseFloat(startAttr);
296
296
  if (isNaN(start)) {
297
297
  const ref =
298
- doc.getElementById(startAttr) || doc.querySelector(`[data-composition-id="${startAttr}"]`);
298
+ doc.getElementById(startAttr) ||
299
+ doc.querySelector(`[data-composition-id="${CSS.escape(startAttr)}"]`);
299
300
  if (ref) {
300
301
  const refStartAttr = ref.getAttribute("data-start") ?? "0";
301
302
  let refStart = parseFloat(refStartAttr);
@@ -303,7 +304,7 @@ export function buildMissingCompositionElements(
303
304
  if (isNaN(refStart)) {
304
305
  const refRef =
305
306
  doc.getElementById(refStartAttr) ||
306
- doc.querySelector(`[data-composition-id="${refStartAttr}"]`);
307
+ doc.querySelector(`[data-composition-id="${CSS.escape(refStartAttr)}"]`);
307
308
  const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
308
309
  const rrCompId = refRef?.getAttribute("data-composition-id");
309
310
  const rrDur =
@@ -400,7 +401,7 @@ export function buildMissingCompositionElements(
400
401
  // Find the matching DOM host by element id or composition id
401
402
  const host =
402
403
  doc.getElementById(existing.id) ??
403
- doc.querySelector(`[data-composition-id="${existing.id}"]`);
404
+ doc.querySelector(`[data-composition-id="${CSS.escape(existing.id)}"]`);
404
405
  if (!host) return existing;
405
406
  const compSrc =
406
407
  host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
@@ -0,0 +1,6 @@
1
+ if (typeof globalThis.CSS === "undefined") {
2
+ (globalThis as Record<string, unknown>).CSS = {};
3
+ }
4
+ if (typeof CSS.escape !== "function") {
5
+ CSS.escape = (value: string) => value.replace(/([^\w-])/g, "\\$1");
6
+ }
@@ -0,0 +1,90 @@
1
+ // keep-in-sync-with: packages/core/src/utils/htmlAttrSafety.ts
2
+ const ALLOWED_HTML_ATTRS = new Set([
3
+ "id",
4
+ "class",
5
+ "style",
6
+ "title",
7
+ "name",
8
+ "for",
9
+ "type",
10
+ "lang",
11
+ "dir",
12
+ "translate",
13
+ "hidden",
14
+ "tabindex",
15
+ "draggable",
16
+ "contenteditable",
17
+ "role",
18
+ "slot",
19
+ "href",
20
+ "target",
21
+ "rel",
22
+ "src",
23
+ "srcset",
24
+ "sizes",
25
+ "alt",
26
+ "poster",
27
+ "loading",
28
+ "decoding",
29
+ "crossorigin",
30
+ "preload",
31
+ "autoplay",
32
+ "loop",
33
+ "muted",
34
+ "controls",
35
+ "playsinline",
36
+ "width",
37
+ "height",
38
+ "colspan",
39
+ "rowspan",
40
+ "scope",
41
+ "placeholder",
42
+ "value",
43
+ "min",
44
+ "max",
45
+ "step",
46
+ "pattern",
47
+ "required",
48
+ "disabled",
49
+ "readonly",
50
+ "checked",
51
+ "selected",
52
+ "multiple",
53
+ "accept",
54
+ "maxlength",
55
+ "minlength",
56
+ "rows",
57
+ "cols",
58
+ "wrap",
59
+ ]);
60
+
61
+ const URI_BEARING_ATTRS = new Set([
62
+ "src",
63
+ "href",
64
+ "action",
65
+ "formaction",
66
+ "poster",
67
+ "srcset",
68
+ "xlink:href",
69
+ ]);
70
+
71
+ const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
72
+ const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;
73
+
74
+ export function isAllowedHtmlAttribute(name: string): boolean {
75
+ const lower = name.toLowerCase();
76
+ if (lower.startsWith("on")) return false;
77
+ if (ALLOWED_HTML_ATTRS.has(lower)) return true;
78
+ if (lower.startsWith("data-")) return true;
79
+ if (lower.startsWith("aria-")) return true;
80
+ return false;
81
+ }
82
+
83
+ export function isSafeAttributeValue(name: string, value: string): boolean {
84
+ if (URI_BEARING_ATTRS.has(name.toLowerCase())) {
85
+ const trimmed = value.trim();
86
+ if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false;
87
+ if (DANGEROUS_DATA_URI.test(trimmed)) return false;
88
+ }
89
+ return true;
90
+ }
@@ -98,6 +98,36 @@ describe("shouldUseSdkCutover", () => {
98
98
  expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("DATA-START", "1")])).toBe(false);
99
99
  });
100
100
 
101
+ it("declines html-attribute ops with event handler names", () => {
102
+ expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onclick", "alert(1)")])).toBe(
103
+ false,
104
+ );
105
+ expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onload", "fetch()")])).toBe(
106
+ false,
107
+ );
108
+ });
109
+
110
+ it("declines html-attribute ops with disallowed attribute names", () => {
111
+ expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("formaction", "/x")])).toBe(false);
112
+ });
113
+
114
+ it("declines html-attribute ops with dangerous URI schemes", () => {
115
+ expect(
116
+ shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("href", "javascript:alert(1)")]),
117
+ ).toBe(false);
118
+ expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("src", "vbscript:run")])).toBe(
119
+ false,
120
+ );
121
+ });
122
+
123
+ it("declines html-attribute ops with dangerous data URIs", () => {
124
+ expect(
125
+ shouldUseSdkCutover(true, true, "hf-abc", [
126
+ htmlAttrOp("href", "data:text/html,<script>alert(1)</script>"),
127
+ ]),
128
+ ).toBe(false);
129
+ });
130
+
101
131
  it("returns true when ops mix all supported types", () => {
102
132
  expect(
103
133
  shouldUseSdkCutover(true, true, "hf-abc", [
@@ -205,6 +235,27 @@ describe("sdkCutoverPersist", () => {
205
235
  });
206
236
  });
207
237
 
238
+ it.each([
239
+ { name: "multi-child targets", children: [{ id: "a" }, { id: "b" }] },
240
+ { name: "single non-html children", children: [{ id: "a", tag: "svg" }] },
241
+ ])("declines text-content cutover for $name", async ({ children }) => {
242
+ const deps = makeDeps();
243
+ const session = makeSession(true);
244
+ (session!.getElement as ReturnType<typeof vi.fn>).mockReturnValue({ children });
245
+ const sel = { hfId: "hf-abc" } as never;
246
+ const result = await sdkCutoverPersist(
247
+ sel,
248
+ [textOp("Hello world")],
249
+ "before",
250
+ "/comp.html",
251
+ session,
252
+ deps,
253
+ );
254
+ expect(result).toBe(false);
255
+ expect(session!.dispatch).not.toHaveBeenCalled();
256
+ expect(deps.writeProjectFile).not.toHaveBeenCalled();
257
+ });
258
+
208
259
  it("dispatches setAttribute for attribute op with data- prefix", async () => {
209
260
  const deps = makeDeps();
210
261
  const session = makeSession(true);
@@ -8,6 +8,7 @@ import { trackStudioEvent } from "./studioTelemetry";
8
8
  import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
9
9
  import { patchOpsToSdkEditOps } from "./sdkOpMapping";
10
10
  import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
11
+ import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety";
11
12
 
12
13
  const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
13
14
  "inline-style",
@@ -52,6 +53,54 @@ function mapsToReservedAttr(op: PatchOperation): boolean {
52
53
  return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase());
53
54
  }
54
55
 
56
+ // ─── html-attribute safety ───────────────────────────────────────────────────
57
+
58
+ function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean {
59
+ return ops.some(
60
+ (op) =>
61
+ op.type === "html-attribute" &&
62
+ (!isAllowedHtmlAttribute(op.property) ||
63
+ (op.value !== null && !isSafeAttributeValue(op.property, op.value))),
64
+ );
65
+ }
66
+
67
+ function hasTextContentOp(ops: PatchOperation[]): boolean {
68
+ return ops.some((op) => op.type === "text-content");
69
+ }
70
+
71
+ function targetChildren(target: unknown): unknown[] | null {
72
+ if (!target || typeof target !== "object" || !("children" in target)) return null;
73
+ const children = (target as { children?: unknown }).children;
74
+ return Array.isArray(children) ? children : null;
75
+ }
76
+
77
+ function elementTag(element: unknown): string | null {
78
+ if (!element || typeof element !== "object" || !("tag" in element)) return null;
79
+ const tag = (element as { tag?: unknown }).tag;
80
+ return typeof tag === "string" ? tag.toLowerCase() : null;
81
+ }
82
+
83
+ // Tags that are non-HTML namespace elements in a linkedom-parsed HTML body.
84
+ // Mirrors the engine's `isHTMLElementTarget` (model.ts) which uses `instanceof
85
+ // HTMLElement` — that runtime check catches the same set, but we can't use it
86
+ // here because `target` is a plain SDK object, not a DOM Element. If linkedom
87
+ // (or a future parser) surfaces additional foreign-content elements as
88
+ // non-HTMLElement, add them here.
89
+ const NON_HTML_CHILD_TAGS = new Set(["svg", "math"]);
90
+
91
+ function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOperation[]): boolean {
92
+ if (!hasTextContentOp(ops)) return false;
93
+ const children = targetChildren(target);
94
+ if (!children) return false;
95
+ // Legacy patch-element replaces the whole element for multi-child targets and
96
+ // for single non-HTML children. The SDK text patch stream stores a scalar
97
+ // inverse, so those shapes cannot be made both byte-identical and undo-safe
98
+ // here. Let the server path remain authoritative for them.
99
+ if (children.length > 1) return true;
100
+ const tag = elementTag(children[0]);
101
+ return tag !== null && NON_HTML_CHILD_TAGS.has(tag);
102
+ }
103
+
55
104
  export function shouldUseSdkCutover(
56
105
  flagEnabled: boolean,
57
106
  hasSession: boolean,
@@ -64,7 +113,8 @@ export function shouldUseSdkCutover(
64
113
  !!hfId &&
65
114
  ops.length > 0 &&
66
115
  ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
67
- !ops.some(mapsToReservedAttr)
116
+ !ops.some(mapsToReservedAttr) &&
117
+ !hasUnsafeHtmlAttributeOp(ops)
68
118
  );
69
119
  }
70
120
 
@@ -186,7 +236,9 @@ export async function sdkCutoverPersist(
186
236
  if (!sdkSession) return false;
187
237
  const hfId = selection.hfId;
188
238
  if (!hfId) return false;
189
- if (!sdkSession.getElement(hfId)) return false;
239
+ const target = sdkSession.getElement(hfId);
240
+ if (!target) return false;
241
+ if (shouldDeclineTextCutoverForTarget(target, ops)) return false;
190
242
  if (wrongCompositionFile(deps, targetPath)) return false;
191
243
  try {
192
244
  const before = sdkSession.serialize();