@hyperframes/studio 0.7.20 → 0.7.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.20",
3
+ "version": "0.7.22",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/player": "0.7.20",
50
- "@hyperframes/parsers": "0.7.20",
51
- "@hyperframes/sdk": "0.7.20",
52
- "@hyperframes/studio-server": "0.7.20",
53
- "@hyperframes/core": "0.7.20"
49
+ "@hyperframes/core": "0.7.22",
50
+ "@hyperframes/parsers": "0.7.22",
51
+ "@hyperframes/sdk": "0.7.22",
52
+ "@hyperframes/player": "0.7.22",
53
+ "@hyperframes/studio-server": "0.7.22"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.20"
68
+ "@hyperframes/producer": "0.7.22"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -36,10 +36,7 @@ import {
36
36
  import type { DomEditSelection } from "./components/editor/domEditing";
37
37
  import { StudioHeader } from "./components/StudioHeader";
38
38
  import { useGestureCommit } from "./hooks/useGestureCommit";
39
- import {
40
- STUDIO_KEYFRAMES_ENABLED,
41
- STUDIO_STORYBOARD_ENABLED,
42
- } from "./components/editor/manualEditingAvailability";
39
+ import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability";
43
40
  import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
44
41
  import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
45
42
  import { StudioPreviewArea } from "./components/StudioPreviewArea";
@@ -65,7 +62,7 @@ type CanvasRect = { left: number; top: number; width: number; height: number };
65
62
  export function StudioApp() {
66
63
  const { projectId, resolving, waitingForServer } = useServerConnection();
67
64
  const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
68
- const viewModeValue = useViewModeState(STUDIO_STORYBOARD_ENABLED);
65
+ const viewModeValue = useViewModeState();
69
66
 
70
67
  // sessionStorage-backed: fires once per tab, survives HMR remounts
71
68
  useEffect(() => {
@@ -3,7 +3,6 @@ import { RotateCcw, RotateCw, Camera } from "../icons/SystemIcons";
3
3
  import {
4
4
  STUDIO_INSPECTOR_PANELS_ENABLED,
5
5
  STUDIO_MANUAL_EDITING_DISABLED_TITLE,
6
- STUDIO_STORYBOARD_ENABLED,
7
6
  } from "./editor/manualEditingAvailability";
8
7
  import { getHistoryShortcutLabel } from "../utils/studioHelpers";
9
8
  import { useStudioShellContext } from "../contexts/StudioContext";
@@ -204,8 +203,8 @@ export function StudioHeader({
204
203
  </span>
205
204
  <span className="text-[11px] font-medium text-neutral-300">{projectId}</span>
206
205
  </div>
207
- {/* Center: storyboard / preview toggle (flag-gated) */}
208
- {STUDIO_STORYBOARD_ENABLED && <ViewModeToggle />}
206
+ {/* Center: storyboard / preview toggle */}
207
+ <ViewModeToggle />
209
208
  {/* Right: toolbar buttons */}
210
209
  <div className="flex items-center gap-1.5">
211
210
  <button
@@ -14,11 +14,10 @@ import {
14
14
  import { MetricField, Section } from "./propertyPanelPrimitives";
15
15
  import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
16
16
  import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
17
- import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
18
- import {
19
- ColorGradingSection,
20
- isColorGradingCapableElement,
21
- } from "./propertyPanelColorGradingSection";
17
+ import { resolveEditingSections } from "@hyperframes/core/editing";
18
+ import { MediaSection } from "./propertyPanelMediaSection";
19
+ import { ColorGradingSection } from "./propertyPanelColorGradingSection";
20
+ import { domEditSelectionToFacts } from "./domEditingLayers";
22
21
  import { TextSection, StyleSections } from "./propertyPanelSections";
23
22
  import { GsapAnimationSection } from "./GsapAnimationSection";
24
23
  import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
@@ -199,6 +198,10 @@ export const PropertyPanel = memo(function PropertyPanel({
199
198
  const manualRotationEditingDisabled = !element.capabilities.canApplyManualRotation;
200
199
  const sourceLabel = element.id ? `#${element.id}` : element.selector;
201
200
  const showEditableSections = element.capabilities.canEditStyles;
201
+ // Capabilities are already resolved on the selection; recompute only sections,
202
+ // feeding the live GSAP tween count (arrives on the gsapAnimations prop, not the
203
+ // selection) so the Timing section shows for pure-GSAP elements with no data-start.
204
+ const sections = resolveEditingSections(domEditSelectionToFacts(element, gsapAnimations.length));
202
205
  const manualOffset = readStudioPathOffset(element.element);
203
206
  const manualSize = readStudioBoxSize(element.element);
204
207
  const resolvedWidth =
@@ -339,7 +342,7 @@ export const PropertyPanel = memo(function PropertyPanel({
339
342
  onRemoveTextField={onRemoveTextField}
340
343
  />
341
344
 
342
- {(element.dataAttributes.start != null || gsapAnimations.length > 0) && (
345
+ {sections.timing && (
343
346
  // Render whenever there's an authored clip range OR animations to infer
344
347
  // one from — a pure-GSAP element with no data-start still gets a Timing
345
348
  // range (TimingSection derives it from its tweens).
@@ -349,7 +352,7 @@ export const PropertyPanel = memo(function PropertyPanel({
349
352
  onSetAttribute={onSetAttribute}
350
353
  />
351
354
  )}
352
- {isMediaElement(element) && (
355
+ {sections.media && (
353
356
  <MediaSection
354
357
  projectDir={projectDir}
355
358
  element={element}
@@ -360,7 +363,7 @@ export const PropertyPanel = memo(function PropertyPanel({
360
363
  />
361
364
  )}
362
365
 
363
- {STUDIO_COLOR_GRADING_ENABLED && isColorGradingCapableElement(element) && (
366
+ {STUDIO_COLOR_GRADING_ENABLED && sections.colorGrading && (
364
367
  <ColorGradingSection
365
368
  key={[
366
369
  element.id ?? "",
@@ -19,39 +19,10 @@ export function isHtmlElement(value: unknown): value is HTMLElement {
19
19
 
20
20
  // ─── Style parsing ────────────────────────────────────────────────────────────
21
21
 
22
- export function parsePx(value: string | undefined): number | null {
23
- if (!value) return null;
24
- const trimmed = value.trim();
25
- if (!trimmed.endsWith("px")) return null;
26
- const parsed = parseFloat(trimmed);
27
- return Number.isFinite(parsed) ? parsed : null;
28
- }
29
-
30
- export function isIdentityTransform(value: string | undefined): boolean {
31
- const transform = (value ?? "none").trim();
32
- if (!transform || transform === "none") return true;
33
-
34
- const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
35
- if (matrix) {
36
- const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
37
- if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
38
- return (
39
- Math.abs(values[0] - 1) < 0.0001 &&
40
- Math.abs(values[1]) < 0.0001 &&
41
- Math.abs(values[2]) < 0.0001 &&
42
- Math.abs(values[3] - 1) < 0.0001 &&
43
- Math.abs(values[4]) < 0.0001 &&
44
- Math.abs(values[5]) < 0.0001
45
- );
46
- }
47
-
48
- const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
49
- if (!matrix3d) return false;
50
- const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
51
- if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
52
- const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
53
- return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
54
- }
22
+ // Single source of truth lives in @hyperframes/core/editing so the studio
23
+ // callers and the core resolver can't drift. Re-exported here to keep this
24
+ // module's public surface (6 studio callers import parsePx from it).
25
+ export { parsePx } from "@hyperframes/core/editing";
55
26
 
56
27
  export function isTextBearingTag(tagName: string): boolean {
57
28
  return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
@@ -3,6 +3,11 @@
3
3
  * for dom editing.
4
4
  */
5
5
  import type { PatchOperation } from "../../utils/sourcePatcher";
6
+ import {
7
+ resolveEditingAffordances,
8
+ resolveEditingSections,
9
+ type EditableElementFacts,
10
+ } from "@hyperframes/core/editing";
6
11
  import { groupScopedLayerRoots, resolveGroupCapture } from "./domEditingGroups";
7
12
  import type {
8
13
  DomEditCapabilities,
@@ -21,9 +26,7 @@ import {
21
26
  getSelectorIndex,
22
27
  getSourceFileForElement,
23
28
  isHtmlElement,
24
- isIdentityTransform,
25
29
  isTextBearingTag,
26
- parsePx,
27
30
  } from "./domEditingDom";
28
31
  import {
29
32
  findElementForSelection,
@@ -171,12 +174,66 @@ export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>):
171
174
 
172
175
  // ─── Capabilities ────────────────────────────────────────────────────────────
173
176
 
174
- // fallow-ignore-next-line complexity
177
+ /**
178
+ * Build the geometry/capability half of EditableElementFacts. Section inputs
179
+ * (text/timing/animation) are irrelevant to capability resolution, so they are
180
+ * zeroed here. Shared by the wrapper and the live-selection path so the two
181
+ * fact-construction sites can't disagree.
182
+ */
183
+ function capabilityFacts(geometry: {
184
+ hasStableTarget: boolean;
185
+ tag: string;
186
+ inlineStyles: Record<string, string>;
187
+ computedStyles: Record<string, string>;
188
+ isCompositionHost: boolean;
189
+ isCompositionRoot: boolean;
190
+ isInsideLockedComposition: boolean;
191
+ isMasterView: boolean;
192
+ existsInSource: boolean;
193
+ }): EditableElementFacts {
194
+ return {
195
+ ...geometry,
196
+ hasEditableText: false,
197
+ hasTimingStart: false,
198
+ animationCount: 0,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Build core EditableElementFacts from a fully-resolved DomEditSelection.
204
+ * `animationCount` is supplied by the caller because live GSAP tweens arrive on
205
+ * a separate channel (the PropertyPanel `gsapAnimations` prop), not on the
206
+ * selection — `selection.gsapAnimations` is never populated.
207
+ */
208
+ export function domEditSelectionToFacts(
209
+ selection: DomEditSelection,
210
+ animationCount = selection.gsapAnimations?.length ?? 0,
211
+ ): EditableElementFacts {
212
+ return {
213
+ hasStableTarget: Boolean(selection.selector || selection.hfId),
214
+ tag: selection.tagName,
215
+ inlineStyles: selection.inlineStyles,
216
+ computedStyles: selection.computedStyles,
217
+ isCompositionHost: selection.isCompositionHost,
218
+ isCompositionRoot: false,
219
+ isInsideLockedComposition: selection.isInsideLockedComposition,
220
+ isMasterView: false,
221
+ existsInSource: true,
222
+ hasEditableText: selection.textFields.length > 0,
223
+ hasTimingStart: selection.dataAttributes.start != null,
224
+ animationCount,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Resolve DOM edit capabilities for a given element.
230
+ * Thin wrapper over core resolveEditingAffordances — kept for backward
231
+ * compatibility (tests and the barrel import this signature directly).
232
+ */
175
233
  export function resolveDomEditCapabilities(args: {
176
234
  selector?: string;
177
235
  hfId?: string;
178
236
  tagName?: string;
179
- className?: string;
180
237
  inlineStyles: Record<string, string>;
181
238
  computedStyles: Record<string, string>;
182
239
  isCompositionHost: boolean;
@@ -185,92 +242,19 @@ export function resolveDomEditCapabilities(args: {
185
242
  isMasterView: boolean;
186
243
  existsInSource?: boolean;
187
244
  }): DomEditCapabilities {
188
- if ((!args.selector && !args.hfId) || args.isInsideLockedComposition) {
189
- return {
190
- canSelect: !args.isInsideLockedComposition,
191
- canEditStyles: false,
192
- canMove: false,
193
- canResize: false,
194
- canApplyManualOffset: false,
195
- canApplyManualSize: false,
196
- canApplyManualRotation: false,
197
- reasonIfDisabled: args.isInsideLockedComposition
198
- ? "This element belongs to a locked composition."
199
- : "Studio could not resolve a stable patch target for this element.",
200
- };
201
- }
202
-
203
- if (args.existsInSource === false) {
204
- return {
205
- canSelect: true,
206
- canEditStyles: false,
207
- canMove: false,
208
- canResize: false,
209
- canApplyManualOffset: false,
210
- canApplyManualSize: false,
211
- canApplyManualRotation: false,
212
- reasonIfDisabled: "This element is generated by a script and cannot be edited visually.",
213
- };
214
- }
215
-
216
- if (args.isCompositionRoot) {
217
- return {
218
- canSelect: true,
219
- canEditStyles: true,
220
- canMove: false,
221
- canResize: false,
222
- canApplyManualOffset: false,
223
- canApplyManualSize: false,
224
- canApplyManualRotation: false,
225
- reasonIfDisabled: "The root composition defines the preview bounds.",
226
- };
227
- }
228
-
229
- const position = args.computedStyles.position;
230
- const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left);
231
- const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
232
- const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width);
233
- const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height);
234
- const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform);
235
-
236
- const canMove =
237
- (position === "absolute" || position === "fixed") &&
238
- left != null &&
239
- top != null &&
240
- !hasTransformDrivenGeometry;
241
-
242
- const canResize = canMove && (width != null || height != null);
243
- const canApplyManualGeometry = !args.isCompositionHost;
244
- const canApplyManualOffset = canApplyManualGeometry;
245
- const canApplyManualSize = canApplyManualGeometry;
246
- const canApplyManualRotation = canApplyManualGeometry;
247
- const reasonIfDisabled = canApplyManualGeometry
248
- ? undefined
249
- : "Select an internal layer to transform it.";
250
-
251
- if (args.isCompositionHost && args.isMasterView) {
252
- return {
253
- canSelect: true,
254
- canEditStyles: false,
255
- canMove,
256
- canResize,
257
- canApplyManualOffset,
258
- canApplyManualSize,
259
- canApplyManualRotation,
260
- reasonIfDisabled,
261
- };
262
- }
263
-
264
- return {
265
- canSelect: true,
266
- canEditStyles: true,
267
- canMove,
268
- canResize,
269
- canApplyManualOffset,
270
- canApplyManualSize,
271
- canApplyManualRotation,
272
- reasonIfDisabled,
273
- };
245
+ return resolveEditingAffordances(
246
+ capabilityFacts({
247
+ hasStableTarget: Boolean(args.selector || args.hfId),
248
+ tag: (args.tagName ?? "div").toLowerCase(),
249
+ inlineStyles: args.inlineStyles,
250
+ computedStyles: args.computedStyles,
251
+ isCompositionHost: args.isCompositionHost,
252
+ isCompositionRoot: args.isCompositionRoot ?? false,
253
+ isInsideLockedComposition: args.isInsideLockedComposition ?? false,
254
+ isMasterView: args.isMasterView,
255
+ existsInSource: args.existsInSource ?? true,
256
+ }),
257
+ ).capabilities;
274
258
  }
275
259
 
276
260
  // ─── Element label ────────────────────────────────────────────────────────────
@@ -354,19 +338,19 @@ export async function resolveDomEditSelection(
354
338
  if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex;
355
339
  existsInSource = await probeSourceElement(options.projectId, sourceFile, probeTarget);
356
340
  }
357
- const capabilities = resolveDomEditCapabilities({
358
- selector,
359
- hfId,
360
- tagName: current.tagName.toLowerCase(),
361
- className: current.className,
362
- inlineStyles,
363
- computedStyles,
364
- isCompositionHost: Boolean(compositionSrc),
365
- isCompositionRoot,
366
- isInsideLockedComposition: isInsideLocked,
367
- isMasterView: options.isMasterView,
368
- existsInSource,
369
- });
341
+ const capabilities = resolveEditingAffordances(
342
+ capabilityFacts({
343
+ hasStableTarget: Boolean(selector || hfId),
344
+ tag: current.tagName.toLowerCase(),
345
+ inlineStyles,
346
+ computedStyles,
347
+ isCompositionHost: Boolean(compositionSrc),
348
+ isCompositionRoot,
349
+ isInsideLockedComposition: isInsideLocked,
350
+ isMasterView: options.isMasterView,
351
+ existsInSource: existsInSource ?? true,
352
+ }),
353
+ ).capabilities;
370
354
  const rect = current.getBoundingClientRect();
371
355
 
372
356
  return {
@@ -554,11 +538,7 @@ export function getDomEditTargetKey(
554
538
  }
555
539
 
556
540
  export function isTextEditableSelection(selection: DomEditSelection): boolean {
557
- return (
558
- selection.textFields.length > 0 &&
559
- !selection.isCompositionHost &&
560
- !selection.isInsideLockedComposition
561
- );
541
+ return resolveEditingSections(domEditSelectionToFacts(selection)).text;
562
542
  }
563
543
 
564
544
  // buildElementAgentPrompt is in domEditingAgentPrompt.ts
@@ -82,16 +82,6 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
82
82
  true,
83
83
  );
84
84
 
85
- // Storyboard view: a top-level, toggleable view that renders STORYBOARD.md as a
86
- // contact sheet of live HTML frame tiles, replacing the timeline/preview stage.
87
- // Opt-in / off by default until the experience is ready for broad exposure.
88
- // VITE_STUDIO_ENABLE_STORYBOARD=1 npx hyperframes preview
89
- export const STUDIO_STORYBOARD_ENABLED = resolveStudioBooleanEnvFlag(
90
- env,
91
- ["VITE_STUDIO_ENABLE_STORYBOARD", "VITE_STUDIO_STORYBOARD_ENABLED"],
92
- false,
93
- );
94
-
95
85
  export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
96
86
 
97
87
  // Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch
@@ -50,10 +50,6 @@ interface RuntimeColorGradingStatus {
50
50
  message: string;
51
51
  }
52
52
 
53
- export function isColorGradingCapableElement(element: DomEditSelection): boolean {
54
- return element.tagName === "video" || element.tagName === "img";
55
- }
56
-
57
53
  function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading {
58
54
  const grading =
59
55
  normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ??
@@ -10,12 +10,6 @@ import {
10
10
  } from "./propertyPanelHelpers";
11
11
  import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
12
12
 
13
- const MEDIA_TAGS = new Set(["video", "audio"]);
14
-
15
- export function isMediaElement(element: DomEditSelection): boolean {
16
- return MEDIA_TAGS.has(element.tagName);
17
- }
18
-
19
13
  export function MediaSection({
20
14
  projectDir,
21
15
  element,
@@ -44,14 +44,11 @@ export interface ViewModeValue {
44
44
  }
45
45
 
46
46
  /**
47
- * Owns the view-mode state. When `enabled` is false (storyboard flag off) the
48
- * mode is pinned to `timeline` and the URL is left untouched, so the feature is
49
- * fully inert until the flag is on.
47
+ * Owns the view-mode state initial read from `?view=`, toggling, popstate sync.
48
+ * Storyboard mode is always available; no flag gating.
50
49
  */
51
- export function useViewModeState(enabled: boolean): ViewModeValue {
52
- const [viewMode, setMode] = useState<StudioViewMode>(() =>
53
- enabled ? readViewModeFromUrl() : "timeline",
54
- );
50
+ export function useViewModeState(): ViewModeValue {
51
+ const [viewMode, setMode] = useState<StudioViewMode>(() => readViewModeFromUrl());
55
52
 
56
53
  // Reflect genuine browser back/forward between history entries with a different
57
54
  // `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
@@ -60,23 +57,17 @@ export function useViewModeState(enabled: boolean): ViewModeValue {
60
57
  // the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
61
58
  // reflected here, by design.
62
59
  useEffect(() => {
63
- if (!enabled) return;
64
60
  const onPopState = () => setMode(readViewModeFromUrl());
65
61
  window.addEventListener("popstate", onPopState);
66
62
  return () => window.removeEventListener("popstate", onPopState);
67
- }, [enabled]);
63
+ }, []);
68
64
 
69
- const setViewMode = useCallback(
70
- (mode: StudioViewMode) => {
71
- if (!enabled) return;
72
- setMode(mode);
73
- writeViewModeToUrl(mode);
74
- },
75
- [enabled],
76
- );
65
+ const setViewMode = useCallback((mode: StudioViewMode) => {
66
+ setMode(mode);
67
+ writeViewModeToUrl(mode);
68
+ }, []);
77
69
 
78
- const effectiveMode = enabled ? viewMode : "timeline";
79
- return useMemo(() => ({ viewMode: effectiveMode, setViewMode }), [effectiveMode, setViewMode]);
70
+ return useMemo(() => ({ viewMode, setViewMode }), [viewMode, setViewMode]);
80
71
  }
81
72
 
82
73
  const ViewModeContext = createContext<ViewModeValue | null>(null);
@@ -260,7 +260,9 @@ export function useDomEditSession({
260
260
  onTrySdkPersist: sdkSession
261
261
  ? (selection, operations, originalContent, targetPath, options) => {
262
262
  // Resolver shadow runs regardless of the cutover flag — decoupled tripwire.
263
- runResolverShadow(sdkSession, selection.hfId, operations);
263
+ // Pass originalContent so the runtime-node filter can suppress hf-ids
264
+ // absent from source (script-created nodes the SDK can't model).
265
+ runResolverShadow(sdkSession, selection.hfId, operations, originalContent);
264
266
  return sdkCutoverPersist(
265
267
  selection,
266
268
  operations,
@@ -226,6 +226,53 @@ describe("C. Resolver-parity detection", () => {
226
226
  });
227
227
  });
228
228
 
229
+ it("C8 runtime-node filter: hfId absent from source → suppressed (not a resolver bug)", () => {
230
+ // The studio resolved a live-DOM element to an hf-id that the SDK session
231
+ // doesn't contain AND that never appears in the on-disk source — it's a
232
+ // node a composition <script> created at runtime (e.g. caption spans). Not
233
+ // a resolver divergence; suppress.
234
+ const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters<
235
+ typeof sdkResolverShadowCheck
236
+ >[0];
237
+ const source = `<div data-hf-id="hf-static">no runtime id here</div>`;
238
+ const mismatches = sdkResolverShadowCheck(
239
+ session,
240
+ "hf-runtimeonly",
241
+ [{ type: "inline-style", property: "color", value: "red" }],
242
+ source,
243
+ );
244
+ expect(mismatches).toHaveLength(0);
245
+ });
246
+
247
+ it("C8 runtime-node filter: hfId PRESENT in source but missing from session → still flagged (real bug)", () => {
248
+ const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters<
249
+ typeof sdkResolverShadowCheck
250
+ >[0];
251
+ const source = `<div data-hf-id="hf-realbug">in source, not in SDK session</div>`;
252
+ const mismatches = sdkResolverShadowCheck(
253
+ session,
254
+ "hf-realbug",
255
+ [{ type: "inline-style", property: "color", value: "red" }],
256
+ source,
257
+ );
258
+ expect(mismatches).toHaveLength(1);
259
+ expect(mismatches[0]?.kind).toBe("element_not_found");
260
+ });
261
+
262
+ it("C8 sourceHfIdCount: emitted element_not_found carries source occurrence count", async () => {
263
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
264
+ const session = { getElement: () => null, getElements: () => [] } as unknown as Composition;
265
+ // id present twice in source (duplicate-id ambiguity) but absent from session
266
+ const source = `<div data-hf-id="hf-dup">a</div><div data-hf-id="hf-dup">b</div>`;
267
+ runResolverShadow(
268
+ session,
269
+ "hf-dup",
270
+ [{ type: "inline-style", property: "color", value: "red" }],
271
+ source,
272
+ );
273
+ expect(lastShadow()?.sourceHfIdCount).toBe(2);
274
+ });
275
+
229
276
  it("C10: unmappable op type produces no mismatch (excluded, not flagged)", async () => {
230
277
  const session = await openComposition(BASE_HTML);
231
278
  // "unknown-op" is not in MAPPED_OP_TYPES, so it must be silently excluded.
@@ -82,6 +82,14 @@ type AttrMap = Record<string, string | null>;
82
82
  * Mirror resolveScoped here: exact scoped-path match, then canonical bare
83
83
  * match, then first bare match — the resolvability dispatch actually has.
84
84
  */
85
+ // Count static `data-hf-id="<id>"` occurrences (both quote styles) in source.
86
+ // Substring split, not regex — no escaping, and the id never contains a quote.
87
+ function countHfIdInSource(source: string, id: string): number {
88
+ return (
89
+ source.split(`data-hf-id="${id}"`).length - 1 + (source.split(`data-hf-id='${id}'`).length - 1)
90
+ );
91
+ }
92
+
85
93
  function resolveSnapshot(session: Composition, id: string): FlatEl | null {
86
94
  const els = session.getElements();
87
95
  const exact = els.find((el) => el.scopedId === id);
@@ -164,8 +172,17 @@ export function sdkResolverShadowCheck(
164
172
  session: Composition,
165
173
  hfId: string,
166
174
  ops: PatchOperation[],
175
+ sourceContent?: string,
167
176
  ): SdkResolverMismatch[] {
168
177
  if (!resolveSnapshot(session, hfId)) {
178
+ // Runtime-node filter: an hf-id absent from the on-disk source the SDK
179
+ // parsed was never in the static DOM — it belongs to an element a
180
+ // composition <script> creates at runtime (e.g. caption word/group spans),
181
+ // which the SDK session cannot model by design. That is NOT a resolver bug,
182
+ // so suppress it. An hf-id PRESENT in source but missing from the session IS
183
+ // a genuine resolver divergence (the v0.6.110 class) — keep emitting that.
184
+ // ponytail: substring match; biases toward keeping signal on a loose hit.
185
+ if (sourceContent !== undefined && !sourceContent.includes(hfId)) return [];
169
186
  return [{ kind: "element_not_found", hfId }];
170
187
  }
171
188
 
@@ -241,17 +258,30 @@ export function runResolverShadow(
241
258
  session: Composition,
242
259
  hfId: string | null | undefined,
243
260
  ops: PatchOperation[],
261
+ sourceContent?: string,
244
262
  ): void {
245
263
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
246
264
  if (!hfId) return;
247
265
  try {
248
- const mismatches = sdkResolverShadowCheck(session, hfId, ops);
266
+ const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
249
267
  // Emit only on divergence — parity is silent, matching recordResolverParity
250
268
  // and recordAnimationResolverParity. Otherwise this fires a PostHog event on
251
269
  // every style/text/attr edit (the editor's chattiest path) at default-ON.
252
270
  if (mismatches.length === 0) return;
271
+ const isElementNotFound = mismatches.some((m) => m.kind === "element_not_found");
253
272
  trackStudioEvent("sdk_resolver_shadow", {
254
273
  hfId,
274
+ // sessionElementCount > 0 + element_not_found = runtime-only element;
275
+ // sessionElementCount === 0 = session is empty/broken (actionable).
276
+ sessionElementCount: session.getElements().length,
277
+ // Count of data-hf-id="<id>" occurrences in source for an emitted
278
+ // element_not_found (the runtime-node filter already dropped absent-from-
279
+ // source ids, so an emitted one is in source ≥1×). >1 = duplicate ids →
280
+ // resolver picked the wrong instance; =1 = single static node the SDK
281
+ // parse dropped (foreign-content exclusion / sub-comp inlining gap).
282
+ ...(isElementNotFound && sourceContent !== undefined
283
+ ? { sourceHfIdCount: countHfIdInSource(sourceContent, hfId) }
284
+ : {}),
255
285
  mismatchCount: mismatches.length,
256
286
  mismatches: JSON.stringify(redactMismatches(mismatches)),
257
287
  });
@@ -282,6 +312,7 @@ export function recordResolverParity(
282
312
  trackStudioEvent("sdk_resolver_shadow", {
283
313
  hfId,
284
314
  opLabel,
315
+ sessionElementCount: session.getElements().length,
285
316
  mismatchCount: 1,
286
317
  mismatches: JSON.stringify([
287
318
  { kind: "element_not_found", hfId } satisfies SdkResolverMismatch,
@@ -310,11 +341,13 @@ export function recordAnimationResolverParity(
310
341
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
311
342
  if (!session || !animationId) return;
312
343
  try {
313
- const resolves = session.getElements().some((el) => el.animationIds.includes(animationId));
344
+ const elements = session.getElements();
345
+ const resolves = elements.some((el) => el.animationIds.includes(animationId));
314
346
  if (resolves) return; // SDK locates the animation — parity
315
347
  trackStudioEvent("sdk_resolver_shadow", {
316
348
  animationId,
317
349
  opLabel,
350
+ sessionElementCount: elements.length,
318
351
  mismatchCount: 1,
319
352
  mismatches: JSON.stringify([
320
353
  { kind: "animation_not_found", animationId } satisfies SdkResolverMismatch,