diff --git a/docs/pr/project-browser/settings.png b/docs/pr/project-browser/settings.png index e3b6b8df..c7822e8c 100644 Binary files a/docs/pr/project-browser/settings.png and b/docs/pr/project-browser/settings.png differ diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index b70345b6..bdb46077 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,3 +1,4 @@ +import { SettingsSections, SettingsCategory } from "./SettingsSections"; import { Card, RadioGroup, Radio, Label, Description } from "@heroui/react"; import { ProgressBar } from "@heroui/react"; import { ColorControl, ColorPalette } from "@/components/ui/color-picker"; @@ -2236,301 +2237,329 @@ export function SettingsPanel({ const effectSectionContent = (() => { const settingsSectionContent = ( -
-
- {t("editor.theme.appearance", "Appearance")} - { - if (value === "light" || value === "dark" || value === "system") - setThemePreference(value); - }} - fullWidth - size="sm" - > - - {t("editor.theme.light", "Light")} - - - {t("editor.theme.dark", "Dark")} - - - {t("editor.theme.system", "System")} - - -
- -
- {t("common.app.language", "Language")} - -
- - {advanced && ( + +
- {tSettings("updates.title", "Updates")} + {t("editor.theme.appearance", "Appearance")} + { + if (value === "light" || value === "dark" || value === "system") + setThemePreference(value); + }} + fullWidth + size="sm" + > + + {t("editor.theme.light", "Light")} + + + {t("editor.theme.dark", "Dark")} + + + {t("editor.theme.system", "System")} + + +
+ +
+ {t("common.app.language", "Language")} + +
+
+ + {advanced && ( +
+ {tSettings("updates.title", "Updates")} +
+
+
+ {tSettings("updates.experimental", "Experimental updates")} +
+
+ {tSettings( + "updates.experimentalDescription", + "This is the front line of user testing - highly experimental so expect bugs", + )} +
+
+ + void updateExperimentalUpdatesPreference(enabled) + } + aria-label={tSettings( + "updates.experimental", + "Experimental updates", + )} + /> +
+
+ )} +
+ +
- {tSettings("updates.experimental", "Experimental updates")} + {tSettings( + "effects.autoApplyFreshRecordingZooms", + "Auto-apply fresh recording zooms", + )}
{tSettings( - "updates.experimentalDescription", - "This is the front line of user testing - highly experimental so expect bugs", + "effects.autoApplyFreshRecordingZoomsDescription", + "Suggest cursor-follow zooms automatically when you open a new recording.", )}
- void updateExperimentalUpdatesPreference(enabled) - } aria-label={tSettings( - "updates.experimental", - "Experimental updates", - )} - /> -
-
- )} - -
-
-
-
- {tSettings( "effects.autoApplyFreshRecordingZooms", "Auto-apply fresh recording zooms", )} -
-
- {tSettings( - "effects.autoApplyFreshRecordingZoomsDescription", - "Suggest cursor-follow zooms automatically when you open a new recording.", - )} -
+ checked={autoApplyFreshRecordingAutoZooms} + onCheckedChange={onAutoApplyFreshRecordingAutoZoomsChange} + />
- -
-
-
-
- {tSettings("effects.connectZooms", "Connect neighboring zooms")} -
-
- {tSettings( - "effects.connectZoomsDescription", - "Smooth consecutive zoom regions into a continuous camera move.", - )} -
-
- -
-
- -
- -
- -
- {t("editor.keyboardShortcuts.title")} - -
- - {advanced && showDevMotionControls ? ( -
-
-
- - {tSettings("effects.devSection", "Dev")} - -
- {tSettings( - "effects.devSectionHint", - "Temporary testing controls for native capture and motion tuning.", - )} -
-
- - DEV - -
- -
-
-
-
- {tSettings( - "effects.nativeCaptureWarningTester", - "Native capture warning", - )} -
-
- {nativeCaptureUnavailableSession - ? tSettings( - "effects.nativeCaptureWarningTesterUnavailable", - "This project is currently marked as native capture unavailable.", - ) - : tSettings( - "effects.nativeCaptureWarningTesterAvailable", - "This project is not marked as unsupported, but you can still open the modal for UI testing.", - )} -
-
- -
-
- -
+
- {tSettings("effects.cameraDebugTuning", "Camera Debug Tuning")} + {tSettings("effects.connectZooms", "Connect neighboring zooms")}
-
+
{tSettings( - "effects.cameraDebugTuningHint", - "Development-only spring tuning controls for camera motion.", + "effects.connectZoomsDescription", + "Smooth consecutive zoom regions into a continuous camera move.", )}
- - onCameraSpringStiffnessMultiplierChange?.(value) - } - formatValue={(value) => `${value.toFixed(2)}×`} - /> - onCameraSpringDampingMultiplierChange?.(value)} - formatValue={(value) => `${value.toFixed(2)}×`} - /> - onCameraSpringMassMultiplierChange?.(value)} - formatValue={(value) => `${value.toFixed(2)}×`} - /> -
- -
-
-
- {tSettings("effects.cursorDebugTuning", "Cursor Debug Tuning")} -
-
- {tSettings( - "effects.cursorDebugTuningHint", - "Development-only spring tuning controls.", - )} -
-
- - onCursorSpringStiffnessMultiplierChange?.(value) - } - formatValue={(value) => `${value.toFixed(2)}×`} - /> - onCursorSpringDampingMultiplierChange?.(value)} - formatValue={(value) => `${value.toFixed(2)}×`} - /> - onCursorSpringMassMultiplierChange?.(value)} - formatValue={(value) => `${value.toFixed(2)}×`} + checked={connectZooms} + onCheckedChange={onConnectZoomsChange} />
- ) : null} -
+ +
+ +
+ + +
+ {t("editor.keyboardShortcuts.title")} + +
+
+ + {advanced && showDevMotionControls ? ( +
+
+
+ + {tSettings("effects.devSection", "Dev")} + +
+ {tSettings( + "effects.devSectionHint", + "Temporary testing controls for native capture and motion tuning.", + )} +
+
+ + DEV + +
+ +
+
+
+
+ {tSettings( + "effects.nativeCaptureWarningTester", + "Native capture warning", + )} +
+
+ {nativeCaptureUnavailableSession + ? tSettings( + "effects.nativeCaptureWarningTesterUnavailable", + "This project is currently marked as native capture unavailable.", + ) + : tSettings( + "effects.nativeCaptureWarningTesterAvailable", + "This project is not marked as unsupported, but you can still open the modal for UI testing.", + )} +
+
+ +
+
+ +
+
+
+ {tSettings( + "effects.cameraDebugTuning", + "Camera Debug Tuning", + )} +
+
+ {tSettings( + "effects.cameraDebugTuningHint", + "Development-only spring tuning controls for camera motion.", + )} +
+
+ + onCameraSpringStiffnessMultiplierChange?.(value) + } + formatValue={(value) => `${value.toFixed(2)}×`} + /> + + onCameraSpringDampingMultiplierChange?.(value) + } + formatValue={(value) => `${value.toFixed(2)}×`} + /> + + onCameraSpringMassMultiplierChange?.(value) + } + formatValue={(value) => `${value.toFixed(2)}×`} + /> +
+ +
+
+
+ {tSettings( + "effects.cursorDebugTuning", + "Cursor Debug Tuning", + )} +
+
+ {tSettings( + "effects.cursorDebugTuningHint", + "Development-only spring tuning controls.", + )} +
+
+ + onCursorSpringStiffnessMultiplierChange?.(value) + } + formatValue={(value) => `${value.toFixed(2)}×`} + /> + + onCursorSpringDampingMultiplierChange?.(value) + } + formatValue={(value) => `${value.toFixed(2)}×`} + /> + + onCursorSpringMassMultiplierChange?.(value) + } + formatValue={(value) => `${value.toFixed(2)}×`} + /> +
+
+ ) : null} +
+ ); const sceneSectionContent = ( diff --git a/src/components/video-editor/SettingsSections.tsx b/src/components/video-editor/SettingsSections.tsx new file mode 100644 index 00000000..22215ffe --- /dev/null +++ b/src/components/video-editor/SettingsSections.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext, useState, type ReactNode } from "react"; +import { ChoiceGroup, ChoiceItem } from "@/components/ui/choice-group"; + +type Category = "general" | "motion" | "recording" | "files" | "advanced"; +const labels: Record = { + general: "General", + motion: "Motion", + recording: "Recording", + files: "Files", + advanced: "Advanced", +}; +const SettingsCategoryContext = createContext(null); + +/** Dashboard and editor share the same controls and category selection. */ +export function SettingsSections({ + children, + categories, +}: { + children: ReactNode; + categories: Category[]; +}) { + const parent = useContext(SettingsCategoryContext); + const [selected, setSelected] = useState("general"); + if (parent) return
{children}
; + const active = categories.includes(selected) ? selected : categories[0]; + return ( + +
+ setSelected(value as Category)} + size="sm" + > + {categories.map((category) => ( + + {labels[category]} + + ))} + +
+ {children} +
+
+
+ ); +} + +export function SettingsCategory({ + category, + children, +}: { + category: Category | Category[]; + children: ReactNode; +}) { + const active = useContext(SettingsCategoryContext); + const visible = Array.isArray(category) + ? active !== null && category.includes(active) + : active === category; + return visible ?
{children}
: null; +} diff --git a/src/components/video-editor/dashboard/DashboardSettings.tsx b/src/components/video-editor/dashboard/DashboardSettings.tsx index aef8ddde..f159983c 100644 --- a/src/components/video-editor/dashboard/DashboardSettings.tsx +++ b/src/components/video-editor/dashboard/DashboardSettings.tsx @@ -1,3 +1,4 @@ +import { SettingsSections, SettingsCategory } from "../SettingsSections"; import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; import { Switch } from "@/components/ui/switch"; import { supportsHudCaptureProtection } from "@/lib/hudCaptureProtection"; @@ -42,119 +43,131 @@ export function DashboardSettings({ onImportFile }: { onImportFile: () => Promis return (

Settings

-
- {settingsContent} -
-

Open video or project

- -
-
-
-

Recordings folder

-

- {recordings} -

-
- -
- {captureSupported && ( + + + {settingsContent} + +
-
-

Hide HUD from recordings

-

- Only while recording. The idle HUD stays visible in captures. +

Open video or project

+ +
+ + +
+
+

Recordings folder

+

+ {recordings}

- - void run(async () => { - const result = - await window.electronAPI.setHudOverlayCaptureProtection( - enabled, - ); - if (!result.success) - throw Error("Could not update capture protection"); - setHideHud(result.enabled); - }) - } - /> -
- )} - {import.meta.env.DEV && ( -
-

Preview update UI

- )} -
-
-

Projects folder

- {directory && ( -

+

+

Hide HUD from recordings

+

+ Only while recording. The idle HUD stays visible in captures. +

+
+ + void run(async () => { + const result = + await window.electronAPI.setHudOverlayCaptureProtection( + enabled, + ); + if (!result.success) + throw Error("Could not update capture protection"); + setHideHud(result.enabled); + }) + } + /> +
+ )} + + + {import.meta.env.DEV && ( +
+

Preview update UI

+ +
+ )} +
+ +
+
+

Projects folder

+ {directory && ( +

+ {directory} +

+ )} +
+
- -
-

- Named projects save automatically. Previews refresh when you return to Projects. -

-
+

+ Named projects save automatically. Previews refresh when you return to + Projects. +

+
+
); } diff --git a/src/components/video-editor/hooks/useZoomRegionCommands.ts b/src/components/video-editor/hooks/useZoomRegionCommands.ts index 513653c7..23206915 100644 --- a/src/components/video-editor/hooks/useZoomRegionCommands.ts +++ b/src/components/video-editor/hooks/useZoomRegionCommands.ts @@ -3,6 +3,7 @@ import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback import { clampFocusToDepth, DEFAULT_AUTO_ZOOM_DEPTH, + DEFAULT_ZOOM_DEPTH, type EditorEffectSection, type ZoomDepth, type ZoomFocus, @@ -72,7 +73,7 @@ export function useZoomRegionCommands({ const handleZoomAdded = useCallback( (span: Span) => { const id = `zoom-${nextZoomIdRef.current++}`; - const depth: ZoomDepth = 2; + const depth = DEFAULT_ZOOM_DEPTH; const newRegion: ZoomRegion = { id, startMs: Math.round(span.start), diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 1839ba01..4c947cea 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -647,7 +647,7 @@ export const ZOOM_DEPTH_SCALES: Record = { }; export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3; -export const DEFAULT_AUTO_ZOOM_DEPTH: ZoomDepth = 2; +export const DEFAULT_AUTO_ZOOM_DEPTH: ZoomDepth = DEFAULT_ZOOM_DEPTH; export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus { return { diff --git a/tests/ui/desktop-windows.spec.ts b/tests/ui/desktop-windows.spec.ts index bc00dee8..cfcd6e48 100644 --- a/tests/ui/desktop-windows.spec.ts +++ b/tests/ui/desktop-windows.spec.ts @@ -30,6 +30,7 @@ test("editor inspector and playback controls fit a smaller desktop", async ({ pa await expect(page.getByRole("button", { name: "Export", exact: true })).toBeInViewport(); await expect(page.getByRole("button", { name: "Play", exact: true })).toBeInViewport(); await page.getByRole("radio", { name: "Settings", exact: true }).click(); + await page.getByRole("row", { name: "Motion", exact: true }).click(); await expect( page.getByRole("radiogroup", { name: "Motion Presets", exact: true }), ).toBeVisible(); diff --git a/tests/ui/editor-layout.spec.ts b/tests/ui/editor-layout.spec.ts index bdb9f675..562aa3aa 100644 --- a/tests/ui/editor-layout.spec.ts +++ b/tests/ui/editor-layout.spec.ts @@ -140,3 +140,14 @@ test("header name edits in place and saves on blur without a boxed input", async await input.press("Escape"); await expect(page.getByRole("button", { name: "Rename project" })).toContainText("Launch demo"); }); + +test("new zoom blocks default to 1.8x", async ({ page }) => { + await installDesktopBridge(page); + await page.goto("/?windowType=editor"); + await page.getByRole("button", { name: "Add Zoom (Z)", exact: true }).click(); + await page.locator('[data-variant="zoom"] .timeline-block').first().click(); + await expect(page.getByRole("row", { name: "1.8×", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); +}); diff --git a/tests/ui/editor.spec.ts b/tests/ui/editor.spec.ts index 9bb68be1..fe4127a0 100644 --- a/tests/ui/editor.spec.ts +++ b/tests/ui/editor.spec.ts @@ -64,7 +64,9 @@ test("editor loads video, switches tools and edits export options", async ({ pag animations: "disabled", }); await page.getByRole("button", { name: "Home", exact: true }).click(); - await expect(page.getByRole("dialog", { name: "Projects", exact: true })).toBeVisible(); + await expect( + page.getByRole("dialog", { name: "Projects dashboard", exact: true }), + ).toBeVisible(); await page.keyboard.press("Escape"); await page.getByRole("button", { name: "Crop Video", exact: true }).click(); await expect(page.getByRole("dialog", { name: "Crop Video", exact: true })).toBeVisible(); @@ -72,15 +74,7 @@ test("editor loads video, switches tools and edits export options", async ({ pag await page.getByRole("button", { name: "16:9", exact: true }).click(); await page.getByRole("menuitem", { name: "1:1", exact: true }).click(); await expect(page.getByRole("button", { name: "1:1", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Open presets", exact: true }).click(); - await page - .getByRole("textbox", { name: "Preset name", exact: true }) - .fill("HeroUI test preset"); - await page.getByRole("button", { name: "Save", exact: true }).click(); - await expect( - page.getByRole("button", { name: "Delete preset HeroUI test preset", exact: true }), - ).toBeVisible(); - await page.keyboard.press("Escape"); + await expect(page.getByRole("button", { name: "Open presets", exact: true })).toHaveCount(0); await page.getByRole("button", { name: "Add Layer", exact: true }).click(); await page.getByRole("menuitem", { name: "Annotation", exact: true }).click(); await expect @@ -117,27 +111,9 @@ test("recorder opens device and source popovers", async ({ page }) => { await page.getByRole("button", { name: "Built-in Display", exact: true }).click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.keyboard.press("Escape"); - await page.getByRole("button", { name: "More", exact: true }).click(); - await page.getByRole("button", { name: "Dark", exact: true }).click(); - await expect(page.locator("html")).toHaveClass(/dark/); - await page.screenshot({ - path: "test-results/hud-dark.png", - fullPage: true, - animations: "disabled", - }); - await page.getByRole("button", { name: "More", exact: true }).click(); - await page.getByRole("button", { name: "Light", exact: true }).click(); - await page.getByRole("button", { name: "More", exact: true }).click(); - await page - .getByRole("dialog") - .getByRole("button", { name: "Open project", exact: false }) - .click(); - await expect(page.getByText("No saved projects yet", { exact: true })).toBeVisible(); - await page.keyboard.press("Escape"); - await page.screenshot({ - path: "test-results/hud-light.png", - fullPage: true, - animations: "disabled", - }); + await expect(page.getByRole("button", { name: "More", exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Home", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-dashboard-opened", "true"); + expect(errors).toEqual([]); }); diff --git a/tests/ui/project-dashboard.spec.ts b/tests/ui/project-dashboard.spec.ts index fc51e905..bcfc0fa3 100644 --- a/tests/ui/project-dashboard.spec.ts +++ b/tests/ui/project-dashboard.spec.ts @@ -381,17 +381,27 @@ test("dashboard supports creation sort, independent folders, shared settings and await expect(home.getByRole("textbox", { name: "Search projects" })).toHaveCount(0); await expect(home.getByRole("button", { name: "Sort projects" })).toHaveCount(0); await expect(home.getByRole("row", { name: "Dark", exact: true })).toBeVisible(); + await expect(home.getByRole("grid", { name: "Settings sections" })).toHaveCount(1); + await expect(home.getByRole("switch", { name: "Connect Zooms" })).toHaveCount(0); + await home.getByRole("row", { name: "Advanced", exact: true }).click(); await expect(home.getByRole("switch", { name: "Experimental updates" })).toBeVisible(); + await expect(home.getByText("Preview update UI", { exact: true })).toBeVisible(); + await home.getByRole("row", { name: "Motion", exact: true }).click(); await expect(home.getByRole("switch", { name: "Connect Zooms" })).toBeVisible(); + await home.getByRole("row", { name: "Recording", exact: true }).click(); await expect(home.getByText("Recordings folder", { exact: true })).toBeVisible(); await home.getByRole("button", { name: "Change folder" }).click(); await expect(home.getByText("/new-recordings", { exact: true })).toBeVisible(); const capture = home.getByRole("switch", { name: "Hide HUD from recordings" }); await capture.press("Space"); await expect(page.locator("html")).toHaveAttribute("data-hide-hud", "true"); + await home.getByRole("row", { name: "Files", exact: true }).click(); await expect(home.getByRole("button", { name: "Open file", exact: true })).toBeVisible(); - await expect(home.getByText("Preview update UI", { exact: true })).toBeVisible(); - await page.screenshot({ path: "test-results/dashboard-settings.png" }); + await home.getByRole("row", { name: "Recording", exact: true }).click(); + await expect(home.getByText("/new-recordings", { exact: true })).toBeVisible(); + await home.getByRole("row", { name: "General", exact: true }).click(); + await expect(home.getByRole("row", { name: "Dark", exact: true })).toBeVisible(); + await page.screenshot({ path: "test-results/dashboard-settings.png", animations: "disabled" }); }); test("Solar navigation selection, circular initials, and Raw sources are consistent", async ({