@hyperframes/studio 0.6.113 → 0.6.115

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.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Pure manifest-transform helpers for SlideshowPanel.
3
+ * No React, no side-effects — fully unit-testable.
4
+ */
5
+
6
+ import type { SlideshowManifest, SlideRef, SlideHotspot } from "@hyperframes/core/slideshow";
7
+
8
+ // ── Scene shape used by the panel UI ──────────────────────────────────────
9
+
10
+ export interface SceneInfo {
11
+ id: string;
12
+ label: string;
13
+ start: number;
14
+ duration: number;
15
+ }
16
+
17
+ // ── Pure manifest transforms ───────────────────────────────────────────────
18
+
19
+ /** Toggle a scene in the main-line slide list. */
20
+ export function toggleMainLineSlide(
21
+ manifest: SlideshowManifest,
22
+ sceneId: string,
23
+ ): SlideshowManifest {
24
+ const exists = manifest.slides.some((s) => s.sceneId === sceneId);
25
+ const slides: SlideRef[] = exists
26
+ ? manifest.slides.filter((s) => s.sceneId !== sceneId)
27
+ : [...manifest.slides, { sceneId }];
28
+ return { ...manifest, slides };
29
+ }
30
+
31
+ // fallow-ignore-next-line complexity
32
+ /** Move a main-line slide up or down by one position. */
33
+ /** Swap the slide with `sceneId` one step up/down within a slide list. */
34
+ function swapSlide(slides: SlideRef[], sceneId: string, direction: "up" | "down"): SlideRef[] {
35
+ const idx = slides.findIndex((s) => s.sceneId === sceneId);
36
+ if (idx === -1) return slides;
37
+ const next = direction === "up" ? idx - 1 : idx + 1;
38
+ if (next < 0 || next >= slides.length) return slides;
39
+ const out = [...slides];
40
+ const a = out[idx];
41
+ const b = out[next];
42
+ if (!a || !b) return slides;
43
+ out[idx] = b;
44
+ out[next] = a;
45
+ return out;
46
+ }
47
+
48
+ export function reorderMainLineSlide(
49
+ manifest: SlideshowManifest,
50
+ sceneId: string,
51
+ direction: "up" | "down",
52
+ ): SlideshowManifest {
53
+ return mapSlidesIn(manifest, undefined, (slides) => swapSlide(slides, sceneId, direction));
54
+ }
55
+
56
+ /** Reorder a slide within a branch sequence (parallel to reorderMainLineSlide). */
57
+ export function reorderBranchSlide(
58
+ manifest: SlideshowManifest,
59
+ sequenceId: string,
60
+ sceneId: string,
61
+ direction: "up" | "down",
62
+ ): SlideshowManifest {
63
+ return mapSlidesIn(manifest, sequenceId, (slides) => swapSlide(slides, sceneId, direction));
64
+ }
65
+
66
+ /** Apply fn to a branch's slide list (sequenceId) or the main line (undefined). */
67
+ function mapSlidesIn(
68
+ manifest: SlideshowManifest,
69
+ sequenceId: string | undefined,
70
+ fn: (slides: SlideRef[]) => SlideRef[],
71
+ ): SlideshowManifest {
72
+ if (sequenceId === undefined) {
73
+ return { ...manifest, slides: fn(manifest.slides) };
74
+ }
75
+ return {
76
+ ...manifest,
77
+ slideSequences: (manifest.slideSequences ?? []).map((seq) =>
78
+ seq.id === sequenceId ? { ...seq, slides: fn(seq.slides) } : seq,
79
+ ),
80
+ };
81
+ }
82
+
83
+ /** Update notes on a main-line slide (adds slide entry if absent). */
84
+ export function setSlideNotes(
85
+ manifest: SlideshowManifest,
86
+ sceneId: string,
87
+ notes: string,
88
+ sequenceId?: string,
89
+ ): SlideshowManifest {
90
+ return mapSlidesIn(manifest, sequenceId, (slides) => {
91
+ const exists = slides.some((s) => s.sceneId === sceneId);
92
+ if (exists) return slides.map((s) => (s.sceneId === sceneId ? { ...s, notes } : s));
93
+ return sequenceId === undefined ? [...slides, { sceneId, notes }] : slides;
94
+ });
95
+ }
96
+
97
+ /** Push a fragment hold-point time onto a main-line slide. Deduplicates + sorts. */
98
+ export function addFragment(
99
+ manifest: SlideshowManifest,
100
+ sceneId: string,
101
+ time: number,
102
+ sequenceId?: string,
103
+ ): SlideshowManifest {
104
+ return mapSlidesIn(manifest, sequenceId, (slides) => {
105
+ const exists = slides.some((s) => s.sceneId === sceneId);
106
+ if (exists)
107
+ return slides.map((s) => {
108
+ if (s.sceneId !== sceneId) return s;
109
+ const frags = [...new Set([...(s.fragments ?? []), time])].sort((a, b) => a - b);
110
+ return { ...s, fragments: frags };
111
+ });
112
+ return sequenceId === undefined ? [...slides, { sceneId, fragments: [time] }] : slides;
113
+ });
114
+ }
115
+
116
+ /** Remove a fragment hold-point by value from a main-line slide. */
117
+ export function removeFragment(
118
+ manifest: SlideshowManifest,
119
+ sceneId: string,
120
+ time: number,
121
+ sequenceId?: string,
122
+ ): SlideshowManifest {
123
+ return mapSlidesIn(manifest, sequenceId, (slides) =>
124
+ slides.map((s) =>
125
+ s.sceneId === sceneId
126
+ ? { ...s, fragments: (s.fragments ?? []).filter((f) => f !== time) }
127
+ : s,
128
+ ),
129
+ );
130
+ }
131
+
132
+ /** Create a new branch sequence. Rejects duplicate ids. */
133
+ export function createSequence(
134
+ manifest: SlideshowManifest,
135
+ id: string,
136
+ label: string,
137
+ ): SlideshowManifest {
138
+ const existing = manifest.slideSequences ?? [];
139
+ if (existing.some((seq) => seq.id === id)) return manifest;
140
+ return {
141
+ ...manifest,
142
+ slideSequences: [...existing, { id, label, slides: [] }],
143
+ };
144
+ }
145
+
146
+ /** Rename an existing branch sequence label. */
147
+ export function renameSequence(
148
+ manifest: SlideshowManifest,
149
+ id: string,
150
+ label: string,
151
+ ): SlideshowManifest {
152
+ return {
153
+ ...manifest,
154
+ slideSequences: (manifest.slideSequences ?? []).map((seq) =>
155
+ seq.id === id ? { ...seq, label } : seq,
156
+ ),
157
+ };
158
+ }
159
+
160
+ function pruneHotspots(slides: SlideRef[], targetId: string): SlideRef[] {
161
+ return slides.map((s) => {
162
+ if (!s.hotspots) return s;
163
+ const hotspots = s.hotspots.filter((h) => h.target !== targetId);
164
+ return hotspots.length === s.hotspots.length ? s : { ...s, hotspots };
165
+ });
166
+ }
167
+
168
+ /** Delete a branch sequence by id, removing any hotspot targeting it. */
169
+ export function deleteSequence(manifest: SlideshowManifest, id: string): SlideshowManifest {
170
+ const remainingSequences = (manifest.slideSequences ?? []).filter((seq) => seq.id !== id);
171
+ return {
172
+ ...manifest,
173
+ slides: pruneHotspots(manifest.slides, id),
174
+ slideSequences: remainingSequences.map((seq) => ({
175
+ ...seq,
176
+ slides: pruneHotspots(seq.slides, id),
177
+ })),
178
+ };
179
+ }
180
+
181
+ /** Add or remove a scene slide from a branch sequence. */
182
+ export function assignToBranch(
183
+ manifest: SlideshowManifest,
184
+ sequenceId: string,
185
+ sceneId: string,
186
+ assign: boolean,
187
+ ): SlideshowManifest {
188
+ return {
189
+ ...manifest,
190
+ slideSequences: (manifest.slideSequences ?? []).map((seq) => {
191
+ if (seq.id !== sequenceId) return seq;
192
+ if (assign) {
193
+ if (seq.slides.some((s) => s.sceneId === sceneId)) return seq;
194
+ return { ...seq, slides: [...seq.slides, { sceneId }] };
195
+ }
196
+ return { ...seq, slides: seq.slides.filter((s) => s.sceneId !== sceneId) };
197
+ }),
198
+ };
199
+ }
200
+
201
+ /** Add a hotspot to a main-line slide. */
202
+ export function addHotspot(
203
+ manifest: SlideshowManifest,
204
+ sceneId: string,
205
+ hotspot: SlideHotspot,
206
+ sequenceId?: string,
207
+ ): SlideshowManifest {
208
+ return mapSlidesIn(manifest, sequenceId, (slides) =>
209
+ slides.map((s) => {
210
+ if (s.sceneId !== sceneId) return s;
211
+ const existing = s.hotspots ?? [];
212
+ if (existing.some((h) => h.id === hotspot.id)) return s;
213
+ return { ...s, hotspots: [...existing, hotspot] };
214
+ }),
215
+ );
216
+ }
217
+
218
+ /** Remove a hotspot by id from a main-line slide. */
219
+ export function removeHotspot(
220
+ manifest: SlideshowManifest,
221
+ sceneId: string,
222
+ hotspotId: string,
223
+ sequenceId?: string,
224
+ ): SlideshowManifest {
225
+ return mapSlidesIn(manifest, sequenceId, (slides) =>
226
+ slides.map((s) =>
227
+ s.sceneId === sceneId
228
+ ? { ...s, hotspots: (s.hotspots ?? []).filter((h) => h.id !== hotspotId) }
229
+ : s,
230
+ ),
231
+ );
232
+ }
@@ -0,0 +1,68 @@
1
+ import { useCallback, type MutableRefObject } from "react";
2
+ import type { Composition } from "@hyperframes/sdk";
3
+ import type { SlideshowManifest } from "@hyperframes/core/slideshow";
4
+ import type { EditHistoryKind } from "../utils/editHistory";
5
+ import { persistSlideshowManifest } from "../utils/setSlideshowManifest";
6
+
7
+ export interface UseSlideshowPersistParams {
8
+ sdkSession: Composition | null;
9
+ activeCompPath: string | null;
10
+ readProjectFile: (path: string) => Promise<string>;
11
+ writeProjectFile: (path: string, content: string) => Promise<void>;
12
+ recordEdit: (entry: {
13
+ label: string;
14
+ kind: EditHistoryKind;
15
+ files: Record<string, { before: string; after: string }>;
16
+ }) => Promise<void>;
17
+ reloadPreview: () => void;
18
+ domEditSaveTimestampRef: MutableRefObject<number>;
19
+ /**
20
+ * When provided, rapid writes with the same key coalesce through the
21
+ * save-queue infra (via recordEdit's coalesceKey) so back-to-back persists
22
+ * collapse to a single undo entry rather than polluting history.
23
+ * Pass e.g. `"slideshow-notes:" + activeCompPath` for the notes path.
24
+ */
25
+ coalesceKey?: string;
26
+ }
27
+
28
+ export function useSlideshowPersist({
29
+ sdkSession,
30
+ activeCompPath,
31
+ readProjectFile,
32
+ writeProjectFile,
33
+ recordEdit,
34
+ reloadPreview,
35
+ domEditSaveTimestampRef,
36
+ coalesceKey,
37
+ }: UseSlideshowPersistParams): (manifest: SlideshowManifest) => Promise<void> {
38
+ return useCallback(
39
+ async (manifest: SlideshowManifest) => {
40
+ if (!sdkSession) return;
41
+ const path = activeCompPath ?? "index.html";
42
+ const originalContent = await readProjectFile(path);
43
+ await persistSlideshowManifest({
44
+ manifest,
45
+ sdkSession,
46
+ originalContent,
47
+ targetPath: path,
48
+ deps: {
49
+ editHistory: { recordEdit },
50
+ writeProjectFile,
51
+ reloadPreview,
52
+ domEditSaveTimestampRef,
53
+ },
54
+ coalesceKey,
55
+ });
56
+ },
57
+ [
58
+ sdkSession,
59
+ activeCompPath,
60
+ readProjectFile,
61
+ writeProjectFile,
62
+ recordEdit,
63
+ reloadPreview,
64
+ domEditSaveTimestampRef,
65
+ coalesceKey,
66
+ ],
67
+ );
68
+ }
@@ -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
 
@@ -144,10 +194,11 @@ interface CutoverOptions {
144
194
  skipRefresh?: boolean;
145
195
  }
146
196
 
147
- // ponytail: internal; export only if a third caller appears.
197
+ // ponytail: exported for setSlideshowManifest (third caller — island write bypasses
198
+ // the SDK dispatch path since <script> nodes are not in the element tree).
148
199
  // `after` is serialized once by the caller (which also did the no-op check
149
200
  // against its pre-dispatch snapshot), so this never re-serializes.
150
- async function persistSdkSerialize(
201
+ export async function persistSdkSerialize(
151
202
  after: string,
152
203
  targetPath: string,
153
204
  originalContent: string,
@@ -185,7 +236,9 @@ export async function sdkCutoverPersist(
185
236
  if (!sdkSession) return false;
186
237
  const hfId = selection.hfId;
187
238
  if (!hfId) return false;
188
- if (!sdkSession.getElement(hfId)) return false;
239
+ const target = sdkSession.getElement(hfId);
240
+ if (!target) return false;
241
+ if (shouldDeclineTextCutoverForTarget(target, ops)) return false;
189
242
  if (wrongCompositionFile(deps, targetPath)) return false;
190
243
  try {
191
244
  const before = sdkSession.serialize();