From 3eae67058edf5fcc57afeabd8bbc0f081f613518 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 13:27:16 -0700 Subject: [PATCH 1/7] feat(studio): add FlatToggle primitive --- .../propertyPanelFlatPrimitives.test.tsx | 42 +++++++++++++++++ .../editor/propertyPanelFlatPrimitives.tsx | 46 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index 2eea86d44d..954ea8052f 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -9,6 +9,7 @@ import { FlatSegmentedRow, FlatSelectRow, FlatSlider, + FlatToggle, PinnedZoneDivider, } from "./propertyPanelFlatPrimitives"; @@ -286,3 +287,44 @@ describe("FlatSelectRow", () => { act(() => root.unmount()); }); }); + +describe("FlatToggle", () => { + it("renders the off state with a dim label and dim knob, and fires onChange(true) on click", () => { + const onChange = vi.fn(); + const { host, root } = renderInto( + , + ); + const label = host.querySelector('[data-flat-toggle-label="true"]'); + expect(label?.className).toContain("text-panel-text-3"); + const pill = host.querySelector('[data-flat-toggle="true"]'); + expect(pill).not.toBeNull(); + act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onChange).toHaveBeenCalledWith(true); + act(() => root.unmount()); + }); + + it("renders the on state with an emphasized label and mint knob, and fires onChange(false) on click", () => { + const onChange = vi.fn(); + const { host, root } = renderInto(); + const label = host.querySelector('[data-flat-toggle-label="true"]'); + expect(label?.className).toContain("text-panel-text-2"); + const knob = host.querySelector('[data-flat-toggle-knob="true"]'); + expect(knob?.className).toContain("bg-panel-accent"); + const pill = host.querySelector('[data-flat-toggle="true"]'); + act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onChange).toHaveBeenCalledWith(false); + act(() => root.unmount()); + }); + + it("does not fire onChange when disabled", () => { + const onChange = vi.fn(); + const { host, root } = renderInto( + , + ); + const pill = host.querySelector('[data-flat-toggle="true"]'); + expect(pill?.disabled).toBe(true); + act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onChange).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index 6cc2217358..e6dc9f1f78 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -375,3 +375,49 @@ export function FlatSelectRow({ ); } + +/* ------------------------------------------------------------------ */ +/* FlatToggle — 24×14 pill switch */ +/* ------------------------------------------------------------------ */ + +export function FlatToggle({ + label, + checked, + disabled, + onChange, +}: { + label: string; + checked: boolean; + disabled?: boolean; + onChange: (next: boolean) => void; +}) { + return ( +
+ + {label} + + +
+ ); +} From 3860a954974bdece6caeea8559f72c7d3a64a98f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 13:35:46 -0700 Subject: [PATCH 2/7] feat(studio): add FlatMediaSection scaffold + source/copy row --- .../propertyPanelFlatMediaSection.test.tsx | 82 ++++++++++++ .../editor/propertyPanelFlatMediaSection.tsx | 125 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx new file mode 100644 index 0000000000..8791748acc --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatMediaSection } from "./propertyPanelFlatMediaSection"; +import type { DomEditSelection } from "./domEditing"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function makeVideoElement(overrides: Partial = {}): DomEditSelection { + const el = document.createElement("video"); + el.setAttribute("src", "assets/intro-loop.mp4"); + return { + element: el, + id: "s1-bg", + selector: "#s1-bg", + label: "S1 Background", + tagName: "video", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 0, y: 0, width: 1920, height: 1080 }, + textContent: "", + dataAttributes: {}, + inlineStyles: {}, + computedStyles: {}, + textFields: [], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + ...overrides, + } as DomEditSelection; +} + +function renderSection(overrides: Partial = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const element = makeVideoElement(overrides); + act(() => { + root.render( + , + ); + }); + return { host, root }; +} + +describe("FlatMediaSection — source row", () => { + it("renders the source path and copies it to clipboard on click", () => { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + const { host, root } = renderSection(); + expect(host.textContent).toContain("assets/intro-loop.mp4"); + const copyButton = host.querySelector('[data-flat-media-copy="true"]'); + expect(copyButton).not.toBeNull(); + act(() => copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("assets/intro-loop.mp4"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx new file mode 100644 index 0000000000..5d249da272 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from "react"; +import { Check, ClipboardList } from "../../icons/SystemIcons"; +import type { DomEditSelection } from "./domEditing"; +import { + type BackgroundRemovalProgress, + type BackgroundRemovalResult, + stripQueryAndHash, +} from "./propertyPanelHelpers"; + +export function FlatMediaSection({ + projectDir, + element, + // oxlint-disable-next-line no-unused-vars -- wired into the Fit/Position rows in Task 6 + styles, + // oxlint-disable-next-line no-unused-vars -- wired into the Fit/Position rows in Task 6 + onSetStyle, + onSetAttribute, + onSetHtmlAttribute, + onRemoveBackground, +}: { + projectDir: string | null; + element: DomEditSelection; + styles: Record; + onSetStyle: (prop: string, value: string) => void | Promise; + onSetAttribute: (attr: string, value: string) => void | Promise; + onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise; + onRemoveBackground?: ( + inputPath: string, + options: { + createBackgroundPlate?: boolean; + quality?: "fast" | "balanced" | "best"; + onProgress?: (progress: BackgroundRemovalProgress) => void; + }, + ) => Promise; +}) { + const isVideo = element.tagName === "video"; + // oxlint-disable-next-line no-unused-vars -- wired into the Volume/Rate/Muted gate in Task 4 + const isAudio = element.tagName === "audio"; + const isImage = element.tagName === "img"; + const isVisualMedia = isVideo || isImage; + const el = element.element; + + const srcAttr = el.getAttribute("src") ?? ""; + const [copied, setCopied] = useState(false); + const [removeBusy, setRemoveBusy] = useState(false); + // oxlint-disable-next-line no-unused-vars -- rendered by the progress bar added in Task 3 + const [removeProgress, setRemoveProgress] = useState(null); + const [createPlate, setCreatePlate] = useState(false); + // oxlint-disable-next-line no-unused-vars -- wired into the Quality FlatSelectRow in Task 3 + const [quality, setQuality] = useState<"fast" | "balanced" | "best">("balanced"); + + const absoluteSrc = + projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr; + const projectSrc = + srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr) + ? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr) + : ""; + // oxlint-disable-next-line no-unused-vars -- gates the Remove BG button added in Task 3 + const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc); + + useEffect(() => { + setRemoveProgress(null); + setCreatePlate(false); + }, [srcAttr]); + + const applyCutoutResult = async (result: BackgroundRemovalResult) => { + await onSetHtmlAttribute("src", result.outputPath); + if (isVideo) { + await onSetAttribute("has-audio", ""); + await onSetHtmlAttribute("muted", "true"); + } + }; + + // oxlint-disable-next-line no-unused-vars -- called by the Remove BG button added in Task 3 + const runBackgroundRemoval = async () => { + if (!onRemoveBackground || !projectSrc || removeBusy) return; + setRemoveBusy(true); + setRemoveProgress({ status: "processing", progress: 0, stage: "Preparing" }); + try { + const result = await onRemoveBackground(projectSrc, { + createBackgroundPlate: isVideo && createPlate, + quality, + onProgress: setRemoveProgress, + }); + await applyCutoutResult(result); + setRemoveProgress({ status: "complete", progress: 100, stage: "Applied cutout", ...result }); + } catch (error) { + setRemoveProgress({ + status: "failed", + progress: 0, + stage: "Failed", + error: error instanceof Error ? error.message : String(error), + }); + } finally { + setRemoveBusy(false); + } + }; + + return ( +
+
+ + + + {srcAttr} + + + +
+
+ ); +} From 70cffa476968a3ad723a6198c5db1ad3c0934c72 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 13:46:19 -0700 Subject: [PATCH 3/7] feat(studio): add cutout sub-block to FlatMediaSection --- .../propertyPanelFlatMediaSection.test.tsx | 49 +++++++++++++++ .../editor/propertyPanelFlatMediaSection.tsx | 60 +++++++++++++++++-- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx index 8791748acc..b20222a67a 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx @@ -80,3 +80,52 @@ describe("FlatMediaSection — source row", () => { act(() => root.unmount()); }); }); + +describe("FlatMediaSection — cutout", () => { + it("shows the WebM label for video and fires background removal on click", async () => { + const onRemoveBackground = vi.fn().mockResolvedValue({ outputPath: "assets/intro-loop.webm" }); + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const element = makeVideoElement(); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).toContain("transparent WebM"); + const removeBgButton = host.querySelector( + '[data-flat-media-remove-bg="true"]', + ); + expect(removeBgButton).not.toBeNull(); + await act(async () => { + removeBgButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onRemoveBackground).toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("toggles BG plate via FlatToggle", () => { + const { host, root } = renderSection(); + const plateToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="BG plate"]', + ); + expect(plateToggle).not.toBeNull(); + expect(plateToggle?.getAttribute("aria-checked")).toBe("false"); + act(() => plateToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(plateToggle?.getAttribute("aria-checked")).toBe("true"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index 5d249da272..4daf973bab 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -6,7 +6,9 @@ import { type BackgroundRemovalResult, stripQueryAndHash, } from "./propertyPanelHelpers"; +import { FlatSelectRow, FlatToggle } from "./propertyPanelFlatPrimitives"; +// fallow-ignore-next-line complexity export function FlatMediaSection({ projectDir, element, @@ -43,10 +45,8 @@ export function FlatMediaSection({ const srcAttr = el.getAttribute("src") ?? ""; const [copied, setCopied] = useState(false); const [removeBusy, setRemoveBusy] = useState(false); - // oxlint-disable-next-line no-unused-vars -- rendered by the progress bar added in Task 3 const [removeProgress, setRemoveProgress] = useState(null); const [createPlate, setCreatePlate] = useState(false); - // oxlint-disable-next-line no-unused-vars -- wired into the Quality FlatSelectRow in Task 3 const [quality, setQuality] = useState<"fast" | "balanced" | "best">("balanced"); const absoluteSrc = @@ -55,7 +55,6 @@ export function FlatMediaSection({ srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr) ? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr) : ""; - // oxlint-disable-next-line no-unused-vars -- gates the Remove BG button added in Task 3 const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc); useEffect(() => { @@ -71,7 +70,6 @@ export function FlatMediaSection({ } }; - // oxlint-disable-next-line no-unused-vars -- called by the Remove BG button added in Task 3 const runBackgroundRemoval = async () => { if (!onRemoveBackground || !projectSrc || removeBusy) return; setRemoveBusy(true); @@ -120,6 +118,60 @@ export function FlatMediaSection({ {copied ? "Copied" : "Copy"} + {isVisualMedia && ( +
+
+ + Cutout + + transparent {isVideo ? "WebM" : "PNG"} + + + +
+ setQuality(next as typeof quality)} + /> + {isVideo && ( + + )} + {removeProgress && ( +
+
+ + {removeProgress.error ?? removeProgress.stage ?? "Processing"} + + {Math.round(removeProgress.progress)}% +
+
+
+
+
+ )} +
+ )}
); } From 558395a5614cca8de3bb17952c2cdbdce362b096 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 13:57:12 -0700 Subject: [PATCH 4/7] feat(studio): add volume/rate/media-start sliders to FlatMediaSection --- .../propertyPanelFlatMediaSection.test.tsx | 115 ++++++++++++++++++ .../editor/propertyPanelFlatMediaSection.tsx | 52 +++++++- 2 files changed, 165 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx index b20222a67a..958d265da8 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx @@ -129,3 +129,118 @@ describe("FlatMediaSection — cutout", () => { act(() => root.unmount()); }); }); + +describe("FlatMediaSection — volume/rate/media-start", () => { + it("renders volume at its stored percentage and commits a new value on drag", () => { + const onSetAttribute = vi.fn(); + const element = makeVideoElement({ dataAttributes: { volume: "0.5" } }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).toContain("50%"); + act(() => root.unmount()); + }); + + it("commits a new volume value on slider track pointerdown", () => { + const onSetAttribute = vi.fn(); + const element = makeVideoElement({ dataAttributes: { volume: "0.5" } }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const volumeTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[0]; + Object.defineProperty(volumeTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); + }); + // min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5" + expect(onSetAttribute).toHaveBeenCalledWith("volume", "0.5"); + act(() => root.unmount()); + }); + + it("commits a new rate value on slider track pointerdown", () => { + const onSetAttribute = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const rateTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[1]; + Object.defineProperty(rateTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + rateTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 })); + }); + // min=25, max=300, ratio=1.0 -> raw=300 -> commit(300) -> 300/100=3 -> "3" + expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "3"); + act(() => root.unmount()); + }); + + it("commits a new media-start value on slider track pointerdown", () => { + const onSetAttribute = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const mediaStartTrack = host.querySelectorAll('[data-flat-slider-track="true"]')[2]; + Object.defineProperty(mediaStartTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + mediaStartTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 })); + }); + // no source-duration set -> mediaStartMax=Math.max(30, Math.ceil(0+10))=30 -> max=3000 + // ratio=1.0 -> raw=3000 -> commit(3000) -> (3000/100).toFixed(2) = "30.00" + expect(onSetAttribute).toHaveBeenCalledWith("media-start", "30.00"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index 4daf973bab..9949489e87 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -4,9 +4,12 @@ import type { DomEditSelection } from "./domEditing"; import { type BackgroundRemovalProgress, type BackgroundRemovalResult, + formatNumericValue, + formatTimingValue, + parseNumericValue, stripQueryAndHash, } from "./propertyPanelHelpers"; -import { FlatSelectRow, FlatToggle } from "./propertyPanelFlatPrimitives"; +import { FlatSelectRow, FlatSlider, FlatToggle } from "./propertyPanelFlatPrimitives"; // fallow-ignore-next-line complexity export function FlatMediaSection({ @@ -36,12 +39,24 @@ export function FlatMediaSection({ ) => Promise; }) { const isVideo = element.tagName === "video"; - // oxlint-disable-next-line no-unused-vars -- wired into the Volume/Rate/Muted gate in Task 4 const isAudio = element.tagName === "audio"; const isImage = element.tagName === "img"; const isVisualMedia = isVideo || isImage; const el = element.element; + const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1; + const volumePercent = Math.round(volume * 100); + const mediaStart = + Number.parseFloat( + element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0", + ) || 0; + const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1; + const sourceDuration = + Number.parseFloat(element.dataAttributes["source-duration"] ?? "") || + (el as HTMLMediaElement).duration || + 0; + const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10)); + const srcAttr = el.getAttribute("src") ?? ""; const [copied, setCopied] = useState(false); const [removeBusy, setRemoveBusy] = useState(false); @@ -172,6 +187,39 @@ export function FlatMediaSection({ )} )} + {(isVideo || isAudio) && ( + <> + void onSetAttribute("volume", formatNumericValue(next / 100))} + /> + + void onSetAttribute("playback-rate", formatNumericValue(next / 100)) + } + /> + void onSetAttribute("media-start", (next / 100).toFixed(2))} + /> + + )} ); } From f6a43a41967d379bb751aa176bbe7c60e204ae9a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 14:01:48 -0700 Subject: [PATCH 5/7] feat(studio): add loop/muted/has-audio-track toggles to FlatMediaSection --- .../propertyPanelFlatMediaSection.test.tsx | 120 ++++++++++++++++++ .../editor/propertyPanelFlatMediaSection.tsx | 28 ++++ 2 files changed, 148 insertions(+) diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx index 958d265da8..d77472a884 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx @@ -244,3 +244,123 @@ describe("FlatMediaSection — volume/rate/media-start", () => { act(() => root.unmount()); }); }); + +describe("FlatMediaSection — loop/muted/has-audio", () => { + it("toggles loop via onSetHtmlAttribute and shows has-audio-track for video", () => { + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const loopToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Loop"]', + ); + act(() => loopToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetHtmlAttribute).toHaveBeenCalledWith("loop", "true"); + + const hasAudioToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Has audio track"]', + ); + expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true"); + act(() => root.unmount()); + }); + + it("toggles muted via onSetHtmlAttribute", () => { + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const mutedToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Muted"]', + ); + expect(mutedToggle?.getAttribute("aria-checked")).toBe("false"); + act(() => mutedToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true"); + act(() => root.unmount()); + }); + + it("enables has-audio-track and clears muted on click", () => { + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const hasAudioToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Has audio track"]', + ); + expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("false"); + act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetAttribute).toHaveBeenCalledWith("has-audio", "true"); + expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", null); + act(() => root.unmount()); + }); + + it("disables has-audio-track and sets muted on click", () => { + const onSetHtmlAttribute = vi.fn(); + const onSetAttribute = vi.fn(); + const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const hasAudioToggle = host.querySelector( + '[data-flat-toggle="true"][aria-label="Has audio track"]', + ); + expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true"); + act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetAttribute).toHaveBeenCalledWith("has-audio", ""); + expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index 9949489e87..37f7a0c711 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -56,6 +56,9 @@ export function FlatMediaSection({ (el as HTMLMediaElement).duration || 0; const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10)); + const hasLoop = el.hasAttribute("loop"); + const hasMuted = el.hasAttribute("muted"); + const hasAudio = element.dataAttributes["has-audio"] === "true"; const srcAttr = el.getAttribute("src") ?? ""; const [copied, setCopied] = useState(false); @@ -218,6 +221,31 @@ export function FlatMediaSection({ displayValue={formatTimingValue(mediaStart)} onCommit={(next) => void onSetAttribute("media-start", (next / 100).toFixed(2))} /> + void onSetHtmlAttribute("loop", next ? "true" : null)} + /> + void onSetHtmlAttribute("muted", next ? "true" : null)} + /> + {isVideo && ( + { + if (next) { + void onSetAttribute("has-audio", "true"); + void onSetHtmlAttribute("muted", null); + } else { + void onSetAttribute("has-audio", ""); + void onSetHtmlAttribute("muted", "true"); + } + }} + /> + )} )} From 2b25154d7d253c32c1d23d83493e6491b81b227f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 14:11:17 -0700 Subject: [PATCH 6/7] feat(studio): add fit/position rows to FlatMediaSection --- .../propertyPanelFlatMediaSection.test.tsx | 67 +++++++++++++++++++ .../editor/propertyPanelFlatMediaSection.tsx | 32 ++++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx index d77472a884..cce0af4206 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx @@ -364,3 +364,70 @@ describe("FlatMediaSection — loop/muted/has-audio", () => { act(() => root.unmount()); }); }); + +describe("FlatMediaSection — fit/position", () => { + it("commits object-fit and object-position changes", () => { + const onSetStyle = vi.fn(); + const { host, root } = (() => { + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + return { host, root }; + })(); + const selects = host.querySelectorAll("select"); + const fitSelect = Array.from(selects).find((s) => s.value === "cover"); + expect(fitSelect).not.toBeUndefined(); + act(() => { + if (fitSelect) { + fitSelect.value = "contain"; + fitSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + expect(onSetStyle).toHaveBeenCalledWith("object-fit", "contain"); + act(() => root.unmount()); + }); + + it("commits an object-position change", () => { + const onSetStyle = vi.fn(); + const element = makeVideoElement(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const selects = host.querySelectorAll("select"); + const positionSelect = Array.from(selects).find((s) => s.value === "center"); + expect(positionSelect).not.toBeUndefined(); + act(() => { + if (positionSelect) { + positionSelect.value = "left top"; + positionSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + expect(onSetStyle).toHaveBeenCalledWith("object-position", "left top"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index 37f7a0c711..b3bcca8334 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -15,9 +15,7 @@ import { FlatSelectRow, FlatSlider, FlatToggle } from "./propertyPanelFlatPrimit export function FlatMediaSection({ projectDir, element, - // oxlint-disable-next-line no-unused-vars -- wired into the Fit/Position rows in Task 6 styles, - // oxlint-disable-next-line no-unused-vars -- wired into the Fit/Position rows in Task 6 onSetStyle, onSetAttribute, onSetHtmlAttribute, @@ -59,6 +57,8 @@ export function FlatMediaSection({ const hasLoop = el.hasAttribute("loop"); const hasMuted = el.hasAttribute("muted"); const hasAudio = element.dataAttributes["has-audio"] === "true"; + const objectFit = styles["object-fit"] || "contain"; + const objectPosition = styles["object-position"] || "center"; const srcAttr = el.getAttribute("src") ?? ""; const [copied, setCopied] = useState(false); @@ -248,6 +248,34 @@ export function FlatMediaSection({ )} )} + {isVisualMedia && ( + <> + void onSetStyle("object-fit", next)} + /> + void onSetStyle("object-position", next)} + /> + + )} ); } From 523c461c73f97018f99161409d74d133e5aebc59 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 14:21:03 -0700 Subject: [PATCH 7/7] feat(studio): wire the flat Media group into the one-open accordion --- .../components/editor/PropertyPanel.test.tsx | 116 ++++++++++++++++++ .../components/editor/PropertyPanelFlat.tsx | 41 +++++-- 2 files changed, 144 insertions(+), 13 deletions(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 298773af51..a3d854c7b4 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -615,3 +615,119 @@ describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up f RENDER_TIMEOUT_MS, ); }); + +// Media fixtures (Plan 4 Task 7): the three tag values resolveEditingSections +// turns `media` on for (video/audio/img). Each carries no text fields (so the +// Text group never renders) and sets `element` to a real media node so the +// FlatMediaSection reads a live media element. `as never` casts around the +// element-type mismatch with baseElement()'s HTMLDivElement. +function videoElement() { + return { + ...baseElement(), + id: "s1-bg", + selector: "#s1-bg", + label: "S1 Background", + tagName: "video", + textFields: [], + element: document.createElement("video"), + }; +} + +function imageElement() { + return { + ...baseElement(), + id: "s1-img", + selector: "#s1-img", + label: "S1 Image", + tagName: "img", + textFields: [], + element: document.createElement("img"), + }; +} + +function audioElement() { + return { + ...baseElement(), + id: "s1-audio", + selector: "#s1-audio", + label: "S1 Audio", + tagName: "audio", + textFields: [], + element: document.createElement("audio"), + }; +} + +// All FlatGroup titles currently mounted (open row + every collapsed row). +function flatGroupTitles(host: HTMLElement): string[] { + const open = Array.from( + host.querySelectorAll('[data-flat-group-open="true"] .text-panel-text-0'), + ).map((el) => el.textContent ?? ""); + const collapsed = Array.from( + host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'), + ).map((el) => el.textContent ?? ""); + return [...open, ...collapsed]; +} + +describe("PropertyPanel — Media group (Plan 4)", () => { + it( + "renders the flat Media group and not the legacy MediaSection, for a video element", + async () => { + const { host, root } = await renderPanel(true, videoElement() as never); + // A Media FlatGroup exists (open or collapsed). + expect(flatGroupTitles(host)).toContain("Media"); + // The legacy MediaSection renders its rows inside a `Section` whose + // data-panel-section slug is the media title ("video"/"image"/"audio"). + // On the flat path it's fully replaced, so none of those may appear. + expect(host.querySelector('[data-panel-section="video"]')).toBeNull(); + expect(host.querySelector('[data-panel-section="image"]')).toBeNull(); + expect(host.querySelector('[data-panel-section="audio"]')).toBeNull(); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "one-open accordion: opening Media closes whichever other group was open, and vice versa (5-way exclusivity)", + async () => { + // videoElement() has canEditStyles: true and no text fields, so Style is + // the default-open group; Layout and Media render collapsed alongside it. + const { host, root } = await renderPanel(true, videoElement() as never); + expect(openGroupText(host)).toContain("Style"); + + // Opening Media closes Style. + openFlatGroup(host, "Media"); + const afterMedia = openGroupText(host); + expect(afterMedia).toContain("Media"); + expect(afterMedia).not.toContain("Style"); + + // Reverse direction: opening Layout closes Media — same shared openGroupId. + openFlatGroup(host, "Layout"); + const afterLayout = openGroupText(host); + expect(afterLayout).toContain("Layout"); + expect(afterLayout).not.toContain("Media"); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "gates the Media group exactly like the legacy MediaSection: present for video/img/audio, absent for a plain div/text element", + async () => { + for (const fixture of [videoElement, imageElement, audioElement]) { + const { host, root } = await renderPanel(true, fixture() as never); + expect(flatGroupTitles(host)).toContain("Media"); + act(() => root.unmount()); + } + + // baseElement() is a plain
with text — sections.media is false, so + // no Media group (flat or legacy) may render. + const { host, root } = await renderPanel(true); + expect(flatGroupTitles(host)).not.toContain("Media"); + expect(host.querySelector('[data-panel-section="video"]')).toBeNull(); + expect(host.querySelector('[data-panel-section="image"]')).toBeNull(); + expect(host.querySelector('[data-panel-section="audio"]')).toBeNull(); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 762d50c198..cdb1c9b2a1 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -11,12 +11,12 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection"; import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection"; import { FlatMotionSection } from "./propertyPanelFlatMotionSection"; +import { FlatMediaSection } from "./propertyPanelFlatMediaSection"; import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; import { createGsapLivePreview } from "./gsapLivePreview"; import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections"; import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability"; import { ColorGradingSection } from "./propertyPanelColorGradingSection"; -import { MediaSection } from "./propertyPanelMediaSection"; type EditingSections = ReturnType; @@ -41,8 +41,8 @@ const EMPTY_GSAP_EFFECT_HANDLERS = { * (same one-directional-import precedent as FlatTextSection). Rendered only * when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open/pin group state. * - * The Text/Style/Layout/Motion groups share the one-open accordion. The legacy - * Media and Color-Grading sections render unchanged below the flat groups. + * The Text/Style/Layout/Motion/Media groups share the one-open accordion. The + * legacy Color-Grading section renders unchanged below the flat groups. */ // fallow-ignore-next-line complexity export function PropertyPanelFlat({ @@ -215,7 +215,13 @@ export function PropertyPanelFlat({ // switching the selection re-mounts this component and re-derives the // default instead of preserving stale state across unrelated elements. const [openGroupId, setOpenGroupId] = useState(() => - isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "layout", + isTextEditableSelection(element) + ? "text" + : showEditableSections + ? "style" + : sections.media + ? "media" + : "layout", ); const [pinnedGroupIds, setPinnedGroupIds] = useState([]); @@ -427,15 +433,24 @@ export function PropertyPanelFlat({ /> )} {sections.media && ( - + toggleOpen("media")} + onTogglePin={() => togglePin("media")} + summary={element.tagName} + > + + )} {showEditableSections && (