@hyperframes/studio 0.7.61 → 0.7.63

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 (84) hide show
  1. package/dist/assets/{hyperframes-player-Xvx2hkrc.js → hyperframes-player-BO2UXx-E.js} +1 -1
  2. package/dist/assets/{index-D-uFclFj.js → index-C0A0DlLk.js} +1 -1
  3. package/dist/assets/index-C_7k3kwr.js +426 -0
  4. package/dist/assets/index-Ceyz8Qt2.css +1 -0
  5. package/dist/assets/{index-BCpoiv9S.js → index-_72wCKSP.js} +1 -1
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3504 -2955
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +15 -10
  12. package/src/components/EditorShell.tsx +12 -0
  13. package/src/components/StudioLeftSidebar.tsx +3 -0
  14. package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
  15. package/src/components/editor/PropertyPanelFlat.tsx +6 -0
  16. package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
  17. package/src/components/editor/domEditing.test.ts +34 -0
  18. package/src/components/editor/propertyPanelColor.test.tsx +31 -0
  19. package/src/components/editor/propertyPanelColor.tsx +150 -28
  20. package/src/components/editor/propertyPanelCommitField.tsx +178 -0
  21. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +34 -0
  22. package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -0
  23. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +5 -1
  24. package/src/components/editor/propertyPanelFlatStyleSections.tsx +6 -0
  25. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
  26. package/src/components/editor/propertyPanelFlatTextSection.tsx +8 -0
  27. package/src/components/editor/propertyPanelInputCoverage.test.tsx +4 -1
  28. package/src/components/editor/propertyPanelPrimitives.tsx +3 -100
  29. package/src/components/editor/propertyPanelSections.tsx +2 -2
  30. package/src/components/editor/propertyPanelTypes.ts +2 -0
  31. package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
  32. package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
  33. package/src/components/nle/TimelinePane.tsx +6 -0
  34. package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
  35. package/src/components/sidebar/CompositionsTab.tsx +45 -2
  36. package/src/components/sidebar/LeftSidebar.tsx +3 -0
  37. package/src/contexts/StudioContext.tsx +4 -0
  38. package/src/hooks/timelineEditingHelpers.ts +10 -4
  39. package/src/hooks/usePreviewPersistence.ts +7 -1
  40. package/src/hooks/useRazorSplit.history.test.tsx +21 -28
  41. package/src/hooks/useRazorSplit.test.tsx +20 -9
  42. package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
  43. package/src/hooks/useRazorSplit.ts +78 -350
  44. package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
  45. package/src/hooks/useTimelineAssetDropOps.ts +52 -2
  46. package/src/hooks/useTimelineEditing.ts +19 -17
  47. package/src/hooks/useTimelineGroupEditing.ts +6 -7
  48. package/src/player/components/Timeline.tsx +12 -1
  49. package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
  50. package/src/player/components/timelineAuthoredTrack.ts +29 -0
  51. package/src/player/components/timelineCallbacks.ts +4 -0
  52. package/src/player/components/timelineClipDragCommit.test.ts +176 -0
  53. package/src/player/components/timelineClipDragCommit.ts +77 -105
  54. package/src/player/components/timelineClipDragPreview.test.ts +33 -1
  55. package/src/player/components/timelineClipDragPreview.ts +2 -1
  56. package/src/player/components/timelineCollision.test.ts +18 -0
  57. package/src/player/components/timelineCollision.ts +12 -9
  58. package/src/player/components/timelineDragDrop.ts +28 -7
  59. package/src/player/components/timelineEditCapabilities.ts +4 -1
  60. package/src/player/components/timelineGroupEditing.test.ts +10 -0
  61. package/src/player/components/timelineGroupEditing.ts +4 -3
  62. package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
  63. package/src/player/components/timelineLayout.ts +3 -1
  64. package/src/player/components/timelineZMirror.ts +1 -1
  65. package/src/player/components/useTimelineClipDrag.ts +5 -0
  66. package/src/player/components/useTimelineEditPinning.ts +12 -0
  67. package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
  68. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  69. package/src/player/lib/playbackTypes.ts +2 -0
  70. package/src/player/lib/timelineDOM.test.ts +58 -0
  71. package/src/player/lib/timelineDOM.ts +18 -1
  72. package/src/player/lib/timelineElementHelpers.ts +21 -11
  73. package/src/player/store/playerStore.ts +2 -1
  74. package/src/utils/razorSplitTransaction.test.ts +175 -0
  75. package/src/utils/razorSplitTransaction.ts +200 -0
  76. package/src/utils/studioPreviewHelpers.test.ts +27 -0
  77. package/src/utils/timelineCompositionDrop.test.ts +12 -0
  78. package/src/utils/timelineCompositionDrop.ts +16 -0
  79. package/src/utils/timelineCompositionInsert.test.ts +142 -0
  80. package/src/utils/timelineCompositionInsert.ts +90 -0
  81. package/src/utils/timelineElementSplit.test.ts +14 -2
  82. package/src/utils/timelineElementSplit.ts +5 -1
  83. package/dist/assets/index-2mxh_HSy.js +0 -426
  84. package/dist/assets/index-BhWig0mx.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.61",
3
+ "version": "0.7.63",
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/core": "0.7.61",
50
- "@hyperframes/parsers": "0.7.61",
51
- "@hyperframes/player": "0.7.61",
52
- "@hyperframes/sdk": "0.7.61",
53
- "@hyperframes/studio-server": "0.7.61"
49
+ "@hyperframes/core": "0.7.63",
50
+ "@hyperframes/parsers": "0.7.63",
51
+ "@hyperframes/player": "0.7.63",
52
+ "@hyperframes/studio-server": "0.7.63",
53
+ "@hyperframes/sdk": "0.7.63"
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.61"
68
+ "@hyperframes/producer": "0.7.63"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -23,7 +23,6 @@ import { useDomEditSession } from "./hooks/useDomEditSession";
23
23
  import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
24
24
  import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
25
25
  import { useBlockHandlers } from "./hooks/useBlockHandlers";
26
- import { useAddAssetAtPlayhead } from "./hooks/useAddAssetAtPlayhead";
27
26
  import { useAppHotkeys } from "./hooks/useAppHotkeys";
28
27
  import { useClipboard } from "./hooks/useClipboard";
29
28
  import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers";
@@ -58,6 +57,7 @@ import { FileManagerProvider } from "./contexts/FileManagerContext";
58
57
  import { DomEditProvider } from "./contexts/DomEditContext";
59
58
  import { StudioSplash } from "./components/StudioSplash";
60
59
  import { useServerConnection } from "./hooks/useServerConnection";
60
+ import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
61
61
  import {
62
62
  normalizeStudioCompositionPath,
63
63
  readStudioUrlStateFromWindow,
@@ -65,7 +65,6 @@ import {
65
65
  } from "./utils/studioUrlState";
66
66
  import { trackStudioSessionStart } from "./telemetry/events";
67
67
  import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
68
- type CanvasRect = { left: number; top: number; width: number; height: number };
69
68
  // fallow-ignore-next-line complexity
70
69
  export function StudioApp() {
71
70
  const { projectId, resolving, waitingForServer } = useServerConnection();
@@ -100,9 +99,6 @@ export function StudioApp() {
100
99
  const timelineDuration = usePlayerStore((s) => s.duration);
101
100
  const isPlaying = usePlayerStore((s) => s.isPlaying);
102
101
  const isMasterView = !activeCompPath || activeCompPath === "index.html";
103
- const activePreviewUrl = activeCompPath
104
- ? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
105
- : null;
106
102
  const effectiveTimelineDuration = useMemo(() => {
107
103
  const maxEnd =
108
104
  timelineElements.length > 0
@@ -196,7 +192,13 @@ export function StudioApp() {
196
192
  },
197
193
  [timelineEditing.handleTimelineGroupMove],
198
194
  );
199
- const handleAddAssetAtPlayhead = useAddAssetAtPlayhead(timelineEditing.handleTimelineAssetDrop);
195
+ const {
196
+ addAssetAtPlayhead: handleAddAssetAtPlayhead,
197
+ addCompositionAtPlayhead: handleAddCompositionAtPlayhead,
198
+ } = useTimelineAddAtPlayhead(
199
+ timelineEditing.handleTimelineAssetDrop,
200
+ timelineEditing.handleTimelineCompositionDrop,
201
+ );
200
202
  const {
201
203
  activeBlockParams,
202
204
  setActiveBlockParams,
@@ -338,7 +340,9 @@ export function StudioApp() {
338
340
  const renderClipContent = useRenderClipContent({
339
341
  projectIdRef: fileManager.projectIdRef,
340
342
  compIdToSrc,
341
- activePreviewUrl,
343
+ activePreviewUrl: activeCompPath
344
+ ? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
345
+ : null,
342
346
  effectiveTimelineDuration,
343
347
  });
344
348
  const compositionDimensions = useCompositionDimensions();
@@ -369,14 +373,13 @@ export function StudioApp() {
369
373
  });
370
374
  handleToggleRecordingRef.current = handleToggleRecording;
371
375
  const recordingToggle = STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined;
372
- const canvasRectRef = useRef<CanvasRect | null>(null);
376
+ const canvasRectRef = useRef<DOMRect | null>(null);
373
377
  useLayoutEffect(() => {
374
378
  if (gestureState !== "recording" || !previewIframe) {
375
379
  canvasRectRef.current = null;
376
380
  return;
377
381
  }
378
- const r = previewIframe.getBoundingClientRect();
379
- canvasRectRef.current = { left: r.left, top: r.top, width: r.width, height: r.height };
382
+ canvasRectRef.current = previewIframe.getBoundingClientRect();
380
383
  }, [gestureState, previewIframe]);
381
384
  const handlePreviewIframeRef = useCallback(
382
385
  (iframe: HTMLIFrameElement | null) => {
@@ -511,6 +514,7 @@ export function StudioApp() {
511
514
  lintFindingCount={lintModal?.length ?? findingsByFile.size}
512
515
  lintFindingsByFile={findingsByFile}
513
516
  onAddAssetToTimeline={handleAddAssetAtPlayhead}
517
+ onAddCompositionToTimeline={handleAddCompositionAtPlayhead}
514
518
  />
515
519
  }
516
520
  right={
@@ -540,6 +544,7 @@ export function StudioApp() {
540
544
  handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete}
541
545
  handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop}
542
546
  handleTimelineBlockDrop={handleTimelineBlockDrop}
547
+ handleTimelineCompositionDrop={timelineEditing.handleTimelineCompositionDrop}
543
548
  handlePreviewBlockDrop={handlePreviewBlockDrop}
544
549
  handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
545
550
  handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
@@ -42,6 +42,10 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
42
42
  blockName: string,
43
43
  placement: TimelineDropPlacement,
44
44
  ) => Promise<void> | void;
45
+ handleTimelineCompositionDrop?: (
46
+ sourcePath: string,
47
+ placement: TimelineDropPlacement,
48
+ ) => Promise<void> | void;
45
49
  handlePreviewBlockDrop?: (
46
50
  blockName: string,
47
51
  position: { left: number; top: number },
@@ -72,6 +76,7 @@ export function EditorShell({
72
76
  handleTimelineElementDelete,
73
77
  handleTimelineAssetDrop,
74
78
  handleTimelineBlockDrop,
79
+ handleTimelineCompositionDrop,
75
80
  handlePreviewBlockDrop,
76
81
  handleTimelineFileDrop,
77
82
  handleTimelineElementMove,
@@ -140,6 +145,7 @@ export function EditorShell({
140
145
  onFileDrop={handleTimelineFileDrop}
141
146
  onAssetDrop={handleTimelineAssetDrop}
142
147
  onBlockDrop={handleTimelineBlockDrop}
148
+ onCompositionDrop={handleTimelineCompositionDrop}
143
149
  onDeleteElement={handleTimelineElementDelete}
144
150
  previewOverlay={
145
151
  <PreviewOverlays
@@ -174,6 +180,10 @@ interface EditorShellBodyProps {
174
180
  onFileDrop: (files: File[], placement?: TimelineDropPlacement) => Promise<void> | void;
175
181
  onAssetDrop: (assetPath: string, placement: TimelineDropPlacement) => Promise<void> | void;
176
182
  onBlockDrop?: (blockName: string, placement: TimelineDropPlacement) => Promise<void> | void;
183
+ onCompositionDrop?: (
184
+ sourcePath: string,
185
+ placement: TimelineDropPlacement,
186
+ ) => Promise<void> | void;
177
187
  onDeleteElement: (element: TimelineElement) => Promise<void> | void;
178
188
  }
179
189
 
@@ -189,6 +199,7 @@ function EditorShellBody({
189
199
  onFileDrop,
190
200
  onAssetDrop,
191
201
  onBlockDrop,
202
+ onCompositionDrop,
192
203
  onDeleteElement,
193
204
  }: EditorShellBodyProps) {
194
205
  const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
@@ -233,6 +244,7 @@ function EditorShellBody({
233
244
  onFileDrop={onFileDrop}
234
245
  onAssetDrop={onAssetDrop}
235
246
  onBlockDrop={onBlockDrop}
247
+ onCompositionDrop={onCompositionDrop}
236
248
  onDeleteElement={onDeleteElement}
237
249
  onSelectTimelineElement={onSelectTimelineElement}
238
250
  timelineFooter={
@@ -19,6 +19,7 @@ export interface StudioLeftSidebarProps {
19
19
  lintFindingCount?: number;
20
20
  lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
21
21
  onAddAssetToTimeline?: (path: string) => void;
22
+ onAddCompositionToTimeline?: (path: string) => void;
22
23
  }
23
24
 
24
25
  // fallow-ignore-next-line complexity
@@ -32,6 +33,7 @@ export function StudioLeftSidebar({
32
33
  lintFindingCount,
33
34
  lintFindingsByFile,
34
35
  onAddAssetToTimeline,
36
+ onAddCompositionToTimeline,
35
37
  }: StudioLeftSidebarProps) {
36
38
  const {
37
39
  leftCollapsed,
@@ -150,6 +152,7 @@ export function StudioLeftSidebar({
150
152
  onAddBlock={onAddBlock}
151
153
  onPreviewBlock={onPreviewBlock}
152
154
  onAddAssetToTimeline={onAddAssetToTimeline}
155
+ onAddCompositionToTimeline={onAddCompositionToTimeline}
153
156
  />
154
157
  {/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
155
158
  the absolutely-positioned inner hit area. The outer element is w-[3px] so
@@ -9,6 +9,57 @@ import { DomEditSelectionChrome } from "./DomEditSelectionChrome";
9
9
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
10
 
11
11
  describe("DomEditSelectionChrome crop composition", () => {
12
+ it("renders overlay-only transparent chrome at headline geometry without changing composition bytes", () => {
13
+ const composition = document.implementation.createHTMLDocument();
14
+ composition.body.innerHTML = `
15
+ <section class="hl-block"><div class="hl-mask" style="overflow:hidden;background:transparent">
16
+ <h1 class="hl-text">Launch title</h1>
17
+ </div></section>
18
+ `;
19
+ const headline = composition.querySelector<HTMLElement>(".hl-text")!;
20
+ const before = composition.documentElement.outerHTML;
21
+ const selection = {
22
+ element: headline,
23
+ selector: ".hl-text",
24
+ capabilities: {
25
+ canCrop: false,
26
+ canApplyManualOffset: false,
27
+ canApplyManualSize: false,
28
+ canApplyManualRotation: false,
29
+ },
30
+ } as unknown as DomEditSelection;
31
+ const host = document.createElement("div");
32
+ document.body.append(host);
33
+ const root = createRoot(host);
34
+ act(() => {
35
+ root.render(
36
+ <DomEditSelectionChrome
37
+ selection={selection}
38
+ overlayRect={{ left: 44, top: 52, width: 220, height: 48, editScaleX: 1, editScaleY: 1 }}
39
+ allowCanvasMovement={false}
40
+ boxRef={createRef()}
41
+ boxChromeClass="border border-studio-accent/80"
42
+ boxClipPath={undefined}
43
+ selectionKey="headline"
44
+ groupSelectionCount={0}
45
+ blockedMoveRef={createRef()}
46
+ gestures={{ startGesture: vi.fn() } as never}
47
+ onStyleCommit={vi.fn()}
48
+ onBoxMouseDown={vi.fn()}
49
+ onBoxClick={vi.fn()}
50
+ />,
51
+ );
52
+ });
53
+ const chrome = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
54
+ expect(chrome.style.cssText).toContain("left: 44px");
55
+ expect(chrome.style.cssText).toContain("width: 220px");
56
+ expect(chrome.style.background).toBe("");
57
+ expect(chrome.className).not.toMatch(/bg-/);
58
+ expect(composition.documentElement.outerHTML).toBe(before);
59
+ act(() => root.unmount());
60
+ host.remove();
61
+ });
62
+
12
63
  it("places rotated crop UI in exactly one oriented coordinate plane", () => {
13
64
  const element = document.createElement("div");
14
65
  element.id = "clip";
@@ -78,6 +78,7 @@ export function PropertyPanelFlat({
78
78
  onClearSelection,
79
79
  onUngroup,
80
80
  onSetStyle,
81
+ onPreviewStyle,
81
82
  onSetAttribute,
82
83
  onSetAttributes,
83
84
  onSetAttributeLive,
@@ -86,6 +87,7 @@ export function PropertyPanelFlat({
86
87
  onRemoveBackground,
87
88
  onSetText,
88
89
  onSetTextFieldStyle,
90
+ onPreviewTextFieldStyle,
89
91
  onAddTextField,
90
92
  onRemoveTextField,
91
93
  onAskAgent,
@@ -147,6 +149,7 @@ export function PropertyPanelFlat({
147
149
  | "onClearSelection"
148
150
  | "onUngroup"
149
151
  | "onSetStyle"
152
+ | "onPreviewStyle"
150
153
  | "onSetAttribute"
151
154
  | "onSetAttributes"
152
155
  | "onSetAttributeLive"
@@ -155,6 +158,7 @@ export function PropertyPanelFlat({
155
158
  | "onRemoveBackground"
156
159
  | "onSetText"
157
160
  | "onSetTextFieldStyle"
161
+ | "onPreviewTextFieldStyle"
158
162
  | "onAddTextField"
159
163
  | "onRemoveTextField"
160
164
  | "onAskAgent"
@@ -360,6 +364,7 @@ export function PropertyPanelFlat({
360
364
  onImportFonts={onImportFonts}
361
365
  onSetText={onSetText}
362
366
  onSetTextFieldStyle={onSetTextFieldStyle}
367
+ onPreviewTextFieldStyle={onPreviewTextFieldStyle}
363
368
  onAddTextField={onAddTextField}
364
369
  onRemoveTextField={onRemoveTextField}
365
370
  />
@@ -382,6 +387,7 @@ export function PropertyPanelFlat({
382
387
  styles={styles}
383
388
  assets={assets}
384
389
  onSetStyle={onSetStyle}
390
+ onPreviewStyle={onPreviewStyle}
385
391
  onImportAssets={onImportAssets}
386
392
  gsapBorderRadius={gsapBorderRadius}
387
393
  />
@@ -0,0 +1,105 @@
1
+ // @vitest-environment jsdom
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { patchElementInHtml } from "@hyperframes/studio-server/source-mutation";
6
+ import { describe, expect, it } from "vitest";
7
+ import { buildDomEditStylePatchOperation } from "./domEditing";
8
+
9
+ const fixtureDir = join(
10
+ dirname(fileURLToPath(import.meta.url)),
11
+ "../../../tests/e2e/fixtures/composition-reliability",
12
+ );
13
+
14
+ function fixture(path: string): string {
15
+ return readFileSync(join(fixtureDir, path), "utf8");
16
+ }
17
+
18
+ function parse(html: string): Document {
19
+ return new DOMParser().parseFromString(html, "text/html");
20
+ }
21
+
22
+ function inTemplate(document: Document, selector: string): Element | null {
23
+ for (const template of Array.from(document.querySelectorAll("template"))) {
24
+ const match = template.content.querySelector(selector);
25
+ if (match) return match;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ describe("composition reliability acceptance fixture", () => {
31
+ const indexSource = fixture("index.html");
32
+ const titleSource = fixture("compositions/title-card.html");
33
+ const nestedSource = fixture("compositions/nested-shell.html");
34
+
35
+ it("owns repeated root hosts, a nested host, transparent headline topology, and collisions", () => {
36
+ const index = parse(indexSource);
37
+ const repeated = Array.from(
38
+ index.querySelectorAll('[data-composition-src="compositions/title-card.html"]'),
39
+ );
40
+ expect(repeated).toHaveLength(2);
41
+ expect(repeated.map((host) => host.getAttribute("data-start"))).toEqual(["0", "4"]);
42
+ expect(
43
+ index.querySelector('[data-composition-src="compositions/nested-shell.html"]'),
44
+ ).toBeTruthy();
45
+
46
+ const nested = parse(nestedSource);
47
+ const nestedHost = inTemplate(nested, '[data-composition-src="title-card.html"]');
48
+ expect(nestedHost).toBeTruthy();
49
+ const nestedDependency = nestedHost?.getAttribute("data-composition-src");
50
+ expect(
51
+ nestedDependency ? existsSync(resolve(fixtureDir, "compositions", nestedDependency)) : false,
52
+ ).toBe(true);
53
+
54
+ const title = parse(titleSource);
55
+ const mask = inTemplate(title, ".hl-mask");
56
+ const headline = inTemplate(title, ".hl-mask > .hl-text");
57
+ expect(mask?.textContent).toContain("Reliable compositions");
58
+ expect(inTemplate(title, "style")?.textContent).toMatch(
59
+ /\.hl-mask\s*\{[^}]*overflow:\s*hidden;[^}]*background:\s*transparent;/,
60
+ );
61
+ expect(headline?.tagName).toBe("H1");
62
+
63
+ const collisionA = index.querySelector('[data-hf-id="collision-a"]')!;
64
+ const collisionB = index.querySelector('[data-hf-id="collision-b"]')!;
65
+ const layered = index.querySelector('[data-hf-id="layer-overlap"]')!;
66
+ expect(collisionA.getAttribute("data-track-index")).toBe(
67
+ collisionB.getAttribute("data-track-index"),
68
+ );
69
+ expect(
70
+ Number(collisionA.getAttribute("data-start")) +
71
+ Number(collisionA.getAttribute("data-duration")),
72
+ ).toBe(Number(collisionB.getAttribute("data-start")));
73
+ expect(layered.getAttribute("data-start")).toBe(collisionB.getAttribute("data-start"));
74
+ expect(layered.getAttribute("data-track-index")).not.toBe(
75
+ collisionB.getAttribute("data-track-index"),
76
+ );
77
+ });
78
+
79
+ it("keeps timeline host edits in the root source and headline color in the template source", () => {
80
+ const moved = patchElementInHtml(indexSource, { hfId: "title-host-a" }, [
81
+ { type: "attribute", property: "start", value: "5" },
82
+ { type: "attribute", property: "duration", value: "2" },
83
+ ]);
84
+ expect(moved.matched).toBe(true);
85
+ expect(moved.html).toContain('data-hf-id="title-host-a"');
86
+ expect(moved.html).toContain('data-start="5"');
87
+ expect(moved.html).toContain('data-duration="2"');
88
+ expect(moved.html).not.toContain('data-hf-id="title-text"');
89
+ expect(titleSource).not.toContain("#12b886");
90
+
91
+ const recolored = patchElementInHtml(titleSource, { hfId: "title-text" }, [
92
+ buildDomEditStylePatchOperation("color", "#12b886"),
93
+ ]);
94
+ expect(recolored.matched).toBe(true);
95
+ const recoloredDocument = parse(recolored.html);
96
+ expect(
97
+ inTemplate(recoloredDocument, '[data-hf-id="title-text"]')?.getAttribute("style"),
98
+ ).toContain("color: #12b886");
99
+ expect(
100
+ inTemplate(recoloredDocument, '[data-hf-id="title-mask"]')?.getAttribute("style"),
101
+ ).toBeNull();
102
+ expect(recolored.html).not.toContain('data-hf-id="title-host-a"');
103
+ expect(indexSource).not.toContain("#12b886");
104
+ });
105
+ });
@@ -739,6 +739,40 @@ describe("resolveDomEditSelection", () => {
739
739
  expect(selection?.selector).toBe("#copy");
740
740
  });
741
741
 
742
+ it("keeps a transparent overflow mask structural when directly selecting its headline", async () => {
743
+ const document = createDocument(`
744
+ <template id="source-template"></template>
745
+ <section class="hl-block">
746
+ <div class="hl-mask" style="overflow: hidden; background: transparent">
747
+ <h1 class="hl-text">Launch title</h1>
748
+ </div>
749
+ </section>
750
+ `);
751
+ const headline = document.querySelector<HTMLElement>(".hl-text")!;
752
+ setElementRect(headline, { left: 44, top: 52, width: 220, height: 48 });
753
+ const selection = await resolveDomEditSelection(headline, {
754
+ activeCompositionPath: "index.html",
755
+ isMasterView: false,
756
+ preferClipAncestor: false,
757
+ });
758
+
759
+ expect(selection?.element).toBe(headline);
760
+ expect(selection?.selector).toBe(".hl-text");
761
+ expect(selection?.textFields).toMatchObject([{ source: "self", tagName: "h1" }]);
762
+ expect(selection?.boundingBox).toEqual({ x: 44, y: 52, width: 220, height: 48 });
763
+ // Explicit layer navigation remains free to resolve the structural mask.
764
+ const mask = document.querySelector<HTMLElement>(".hl-mask")!;
765
+ expect(
766
+ (
767
+ await resolveDomEditSelection(mask, {
768
+ activeCompositionPath: "index.html",
769
+ isMasterView: false,
770
+ preferClipAncestor: false,
771
+ })
772
+ )?.element,
773
+ ).toBe(mask);
774
+ });
775
+
742
776
  // fallow-ignore-next-line code-duplication
743
777
  it("collects simple child text blocks as separate editable fields", async () => {
744
778
  const document = createDocument(`
@@ -11,6 +11,15 @@ afterEach(() => {
11
11
  document.body.innerHTML = "";
12
12
  });
13
13
 
14
+ function renderColorField(onCommit: (value: string) => void): void {
15
+ const host = document.createElement("div");
16
+ document.body.append(host);
17
+ const root = createRoot(host);
18
+ act(() => {
19
+ root.render(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={onCommit} />);
20
+ });
21
+ }
22
+
14
23
  describe("ColorField flat trigger", () => {
15
24
  it("renders label and value inline with a small swatch, no boxed border", () => {
16
25
  const host = document.createElement("div");
@@ -25,4 +34,26 @@ describe("ColorField flat trigger", () => {
25
34
  expect(host.textContent).toContain("Color");
26
35
  act(() => root.unmount());
27
36
  });
37
+
38
+ it("persists one keyboard slider gesture on keyup", () => {
39
+ const onCommit = vi.fn();
40
+ renderColorField(onCommit);
41
+ const trigger = document.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
42
+ if (!trigger) throw new Error("Color trigger was not rendered");
43
+ act(() => {
44
+ trigger.click();
45
+ });
46
+ const hue = document.querySelector<HTMLElement>('[role="slider"][aria-label="Hue"]');
47
+ if (!hue) throw new Error("Hue slider was not rendered");
48
+
49
+ act(() => {
50
+ hue.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }));
51
+ });
52
+ expect(onCommit).not.toHaveBeenCalled();
53
+ act(() => {
54
+ hue.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "ArrowRight" }));
55
+ });
56
+
57
+ expect(onCommit).toHaveBeenCalledOnce();
58
+ });
28
59
  });