diff --git a/docs/design-system/evidence/1300/a11y/a11y-proof.json b/docs/design-system/evidence/1300/a11y/a11y-proof.json index c3e28ed9..bf4beeb6 100644 --- a/docs/design-system/evidence/1300/a11y/a11y-proof.json +++ b/docs/design-system/evidence/1300/a11y/a11y-proof.json @@ -2,7 +2,7 @@ "issue": 1300, "epic": 1290, "tool": "axe-core 4.12.0", - "postCssSha256": "421998854817e25ede9edbcec09cdb0da2e994fe68d2bf693bfd0c5253bac538", + "postCssSha256": "e9dfdde86df78e8137ac47448986b1b3ffcc4006567d4f335be3a25b3aa00a7d", "run": { "generatedAt": "2026-06-27T16:18:23.566Z", "repoHeadSha": "29362c523bcbddd665a307dbd7fb91c5fc1a4507" @@ -6127,4 +6127,4 @@ } }, "verdict": "PASS" -} \ No newline at end of file +} diff --git a/docs/design-system/evidence/1300/consolidated-fidelity-proof.json b/docs/design-system/evidence/1300/consolidated-fidelity-proof.json index 94fca3f8..2d17416e 100644 --- a/docs/design-system/evidence/1300/consolidated-fidelity-proof.json +++ b/docs/design-system/evidence/1300/consolidated-fidelity-proof.json @@ -1,7 +1,7 @@ { "issue": 1300, "epic": 1290, - "postCssSha256": "421998854817e25ede9edbcec09cdb0da2e994fe68d2bf693bfd0c5253bac538", + "postCssSha256": "e9dfdde86df78e8137ac47448986b1b3ffcc4006567d4f335be3a25b3aa00a7d", "referenceFiles": [ "keiko-tokens.css", "keiko-semantic-tokens.css", @@ -315,4 +315,4 @@ } }, "verdict": "PASS" -} \ No newline at end of file +} diff --git a/docs/design-system/evidence/1300/editor/editor-fidelity-proof.json b/docs/design-system/evidence/1300/editor/editor-fidelity-proof.json index 5c8372ff..46625761 100644 --- a/docs/design-system/evidence/1300/editor/editor-fidelity-proof.json +++ b/docs/design-system/evidence/1300/editor/editor-fidelity-proof.json @@ -2,7 +2,7 @@ "issue": 1300, "epic": 1290, "surface": "editor", - "postCssSha256": "421998854817e25ede9edbcec09cdb0da2e994fe68d2bf693bfd0c5253bac538", + "postCssSha256": "e9dfdde86df78e8137ac47448986b1b3ffcc4006567d4f335be3a25b3aa00a7d", "referenceFiles": [ "keiko-tokens.css", "keiko-semantic-tokens.css", @@ -658,4 +658,4 @@ "missingReferenceTokenCount": 0, "missing": [], "verdict": "PASS" -} \ No newline at end of file +} diff --git a/packages/keiko-ui/src/app/components/desktop/AppShell.tsx b/packages/keiko-ui/src/app/components/desktop/AppShell.tsx index 020018ce..8d6ddf6b 100644 --- a/packages/keiko-ui/src/app/components/desktop/AppShell.tsx +++ b/packages/keiko-ui/src/app/components/desktop/AppShell.tsx @@ -13,6 +13,10 @@ import { Header, type HeaderStatusTone } from "./Header"; import { LeftRail } from "./LeftRail"; import { RightRail } from "./RightRail"; import { Workspace } from "./Workspace"; +import { + readWorkspaceCameraSmoothness, + WORKSPACE_CAMERA_SMOOTHNESS_EVENT, +} from "./workspace-appearance"; import { CommandPalette, type Command } from "./modals/CommandPalette"; import { GatewaySetupDialog } from "./modals/GatewaySetupDialog"; import { NewWindowDialog } from "./modals/NewWindowDialog"; @@ -537,7 +541,21 @@ function AppShellInner(): ReactNode { }, [chatForWindow, session], ); + const [cameraSmoothness, setCameraSmoothness] = useState(readWorkspaceCameraSmoothness); + + useEffect(() => { + const onCameraSmoothness = (event: Event): void => { + const detail = (event as CustomEvent).detail; + setCameraSmoothness(typeof detail === "number" ? detail : 0); + }; + window.addEventListener(WORKSPACE_CAMERA_SMOOTHNESS_EVENT, onCameraSmoothness); + return () => { + window.removeEventListener(WORKSPACE_CAMERA_SMOOTHNESS_EVENT, onCameraSmoothness); + }; + }, []); + const ws = useWorkspace(wsRef, { + cameraSmoothness, onScopeBind: handleScopeBind, onScopeUnbind: handleScopeUnbind, onConnectorBind: handleConnectorBind, diff --git a/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts b/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts index fa8abe87..103bc363 100644 --- a/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts +++ b/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts @@ -51,6 +51,9 @@ export const MIN_ZOOM = 0.3; export const MAX_ZOOM = 2.5; const CONTENT_MIN_ZOOM = 0.5; const CONTENT_MAX_ZOOM = 2; +const MIN_CAMERA_ANIMATION_DURATION_MS = 90; +const MAX_CAMERA_ANIMATION_DURATION_MS = 280; +const PAN_CAMERA_SMOOTHNESS_SCALE = 0.65; function readView(): View { if (typeof window === "undefined") return { zoom: 1, x: 0, y: 0 }; @@ -252,11 +255,17 @@ function windowIdFromWheelTarget(target: EventTarget | null): string | null { interface UsePanZoomArgs { readonly wsRef: RefObject; readonly view: View; + readonly cameraSmoothness: number; readonly winsRef: MutableRefObject; readonly setView: Dispatch>; readonly setWins: Dispatch>; } +interface QueueViewOptions { + readonly smoothnessScale?: number; + readonly minDurationMs?: number; +} + interface PanZoomResult { readonly viewRef: MutableRefObject; readonly worldVP: () => ViewportWorld | null; @@ -306,11 +315,43 @@ export function fitWorkspaceViewToWindows( }; } -function usePanZoom({ wsRef, view, winsRef, setView, setWins }: UsePanZoomArgs): PanZoomResult { +function prefersReducedCameraMotion(): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +function easeCamera(t: number): number { + return 1 - Math.pow(1 - t, 3); +} + +function interpolateView(from: View, to: View, progress: number): View { + return { + zoom: from.zoom + (to.zoom - from.zoom) * progress, + x: from.x + (to.x - from.x) * progress, + y: from.y + (to.y - from.y) * progress, + }; +} + +function usePanZoom({ + wsRef, + view, + cameraSmoothness, + winsRef, + setView, + setWins, +}: UsePanZoomArgs): PanZoomResult { const viewRef = useRef(view); viewRef.current = view; + const renderedViewRef = useRef(view); + renderedViewRef.current = view; const pendingViewRef = useRef(null); + const pendingViewSmoothnessScaleRef = useRef(1); + const pendingViewMinDurationRef = useRef(MIN_CAMERA_ANIMATION_DURATION_MS); const frameRef = useRef(null); + const animationFrameRef = useRef(null); + const animationStartRef = useRef(view); + const animationTargetRef = useRef(view); + const animationStartedAtRef = useRef(0); const viewPersistDebounceRef = useRef(null); if (viewPersistDebounceRef.current === null) viewPersistDebounceRef.current = createTrailingDebounce(PERSIST_DEBOUNCE_MS); @@ -346,16 +387,98 @@ function usePanZoom({ wsRef, view, winsRef, setView, setWins }: UsePanZoomArgs): if (frameRef.current !== null && typeof window.cancelAnimationFrame === "function") { window.cancelAnimationFrame(frameRef.current); } + if ( + animationFrameRef.current !== null && + typeof window.cancelAnimationFrame === "function" + ) { + window.cancelAnimationFrame(animationFrameRef.current); + } }, [], ); + const animateView = useCallback( + (target: View, smoothnessScale = 1, minDurationMs = MIN_CAMERA_ANIMATION_DURATION_MS): void => { + const effectiveSmoothness = Math.min( + 100, + Math.max(0, cameraSmoothness * smoothnessScale), + ); + if ( + effectiveSmoothness <= 0 || + prefersReducedCameraMotion() || + typeof window.requestAnimationFrame !== "function" || + typeof window.cancelAnimationFrame !== "function" + ) { + if (animationFrameRef.current !== null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + setView(target); + return; + } + + const durationMs = + minDurationMs + + ((MAX_CAMERA_ANIMATION_DURATION_MS - minDurationMs) * effectiveSmoothness) / 100; + const now = + typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now(); + animationStartRef.current = + animationFrameRef.current === null + ? renderedViewRef.current + : interpolateView( + animationStartRef.current, + animationTargetRef.current, + easeCamera( + Math.min(1, (now - animationStartedAtRef.current) / durationMs), + ), + ); + animationTargetRef.current = target; + animationStartedAtRef.current = now; + + if (animationFrameRef.current !== null) return; + const step = (time: number): void => { + const progress = Math.min( + 1, + Math.max(0, (time - animationStartedAtRef.current) / durationMs), + ); + const eased = easeCamera(progress); + if (progress >= 1) { + animationFrameRef.current = null; + setView(animationTargetRef.current); + return; + } + setView(interpolateView(animationStartRef.current, animationTargetRef.current, eased)); + animationFrameRef.current = window.requestAnimationFrame(step); + }; + animationFrameRef.current = window.requestAnimationFrame(step); + }, + [cameraSmoothness, setView], + ); + + const settleCameraAnimation = useCallback((): void => { + if ( + animationFrameRef.current !== null && + typeof window.cancelAnimationFrame === "function" + ) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + setView(animationTargetRef.current); + renderedViewRef.current = animationTargetRef.current; + } + }, [setView]); + const queueView = useCallback( - (next: View | ((current: View) => View)): void => { + (next: View | ((current: View) => View), options: QueueViewOptions = {}): void => { const base = pendingViewRef.current ?? viewRef.current; const resolved = typeof next === "function" ? next(base) : next; + const smoothnessScale = options.smoothnessScale ?? 1; + const minDurationMs = options.minDurationMs ?? MIN_CAMERA_ANIMATION_DURATION_MS; viewRef.current = resolved; pendingViewRef.current = resolved; + pendingViewSmoothnessScaleRef.current = smoothnessScale; + pendingViewMinDurationRef.current = minDurationMs; scheduleViewPersist(); if ( @@ -371,11 +494,15 @@ function usePanZoom({ wsRef, view, winsRef, setView, setWins }: UsePanZoomArgs): frameRef.current = window.requestAnimationFrame(() => { frameRef.current = null; const pending = pendingViewRef.current; + const pendingSmoothnessScale = pendingViewSmoothnessScaleRef.current; + const pendingMinDurationMs = pendingViewMinDurationRef.current; pendingViewRef.current = null; - if (pending !== null) setView(pending); + pendingViewSmoothnessScaleRef.current = 1; + pendingViewMinDurationRef.current = MIN_CAMERA_ANIMATION_DURATION_MS; + if (pending !== null) animateView(pending, pendingSmoothnessScale, pendingMinDurationMs); }); }, - [scheduleViewPersist, setView], + [animateView, scheduleViewPersist, setView], ); useEffect(() => { @@ -386,6 +513,7 @@ function usePanZoom({ wsRef, view, winsRef, setView, setWins }: UsePanZoomArgs): e.preventDefault(); const windowId = windowIdFromWheelTarget(e.target); if (windowId !== null) { + settleCameraAnimation(); setWins((ws) => ws === null ? ws @@ -412,13 +540,16 @@ function usePanZoom({ wsRef, view, winsRef, setView, setWins }: UsePanZoomArgs): } e.preventDefault(); const delta = normalizeWheelDelta(e); - queueView((v) => ({ ...v, x: v.x - delta.x, y: v.y - delta.y })); + queueView((v) => ({ ...v, x: v.x - delta.x, y: v.y - delta.y }), { + minDurationMs: 0, + smoothnessScale: PAN_CAMERA_SMOOTHNESS_SCALE, + }); }; el.addEventListener("wheel", onWheel, { passive: false }); return () => { el.removeEventListener("wheel", onWheel); }; - }, [wsRef, setWins, queueView]); + }, [wsRef, setWins, queueView, settleCameraAnimation]); const rect = useCallback( (): DOMRect | null => (wsRef.current === null ? null : wsRef.current.getBoundingClientRect()), @@ -455,7 +586,11 @@ function usePanZoom({ wsRef, view, winsRef, setView, setWins }: UsePanZoomArgs): const resetView = useCallback((): void => queueView({ zoom: 1, x: 0, y: 0 }), [queueView]); const panBy = useCallback( - (dx: number, dy: number): void => queueView((v) => ({ ...v, x: v.x + dx, y: v.y + dy })), + (dx: number, dy: number): void => + queueView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }), { + minDurationMs: 0, + smoothnessScale: PAN_CAMERA_SMOOTHNESS_SCALE, + }), [queueView], ); @@ -924,6 +1059,7 @@ function useConnectionPrune( // Release 0.2.0 — bind callbacks return whether the bind was ACCEPTED; `false` (source limit // reached or persistence failed) vetoes the edge so no dangling ungrounded edge is drawn. export interface UseWorkspaceOptions { + readonly cameraSmoothness?: number | undefined; readonly onScopeBind?: ((chatWindowId: string, scope: ChatConnectedScope) => boolean | Promise) | undefined; readonly onScopeUnbind?: ((chatWindowId: string, scope: ChatConnectedScope) => void) | undefined; @@ -948,7 +1084,13 @@ export function useWorkspace( // action factories below depend on their (stable) identities rather than on the // `opts` object, which defaults to a fresh `{}` every render and would otherwise // re-create the whole api each frame and defeat memoization. - const { onScopeBind, onScopeUnbind, onConnectorBind, onConnectorUnbind } = opts; + const { + cameraSmoothness = 0, + onScopeBind, + onScopeUnbind, + onConnectorBind, + onConnectorUnbind, + } = opts; const zc = useRef(3); const snapZone = useRef(null); const suppressNextServerPersistRef = useRef(false); @@ -998,6 +1140,7 @@ export function useWorkspace( const { viewRef, worldVP, zoomTo, fitView, resetView, panBy, rect } = usePanZoom({ wsRef, view, + cameraSmoothness, winsRef, setView, setWins, diff --git a/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.wheel.test.tsx b/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.wheel.test.tsx index 04a72924..d3237f75 100644 --- a/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.wheel.test.tsx +++ b/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.wheel.test.tsx @@ -23,9 +23,13 @@ function appWindow(patch: Partial = {}): AppWindow { }; } -function Harness(): ReactElement { +function Harness({ + cameraSmoothness = 0, +}: { + readonly cameraSmoothness?: number; +}): ReactElement { const wsRef = useRef(null); - const ws = useWorkspace(wsRef); + const ws = useWorkspace(wsRef, { cameraSmoothness }); const files = ws.wins?.find((win) => win.id === "files-1"); return ( @@ -168,4 +172,188 @@ describe("useWorkspace wheel zoom routing", () => { expect(screen.getByTestId("view-y")).toHaveTextContent("-35"); }); }); + + it("eases free-workspace pan updates when smooth camera animation is selected", async () => { + const callbacks: FrameRequestCallback[] = []; + vi.spyOn(performance, "now").mockReturnValue(0); + const requestAnimationFrameSpy = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback: FrameRequestCallback): number => { + callbacks.push(callback); + return callbacks.length; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); + vi.spyOn(window, "matchMedia").mockImplementation( + () => + ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) as unknown as MediaQueryList, + ); + window.localStorage.setItem(WORKSPACE_STORAGE_KEY, JSON.stringify([appWindow()])); + render(); + mockWorkspaceRect(); + + await waitFor(() => expect(screen.getByTestId("view-x")).toHaveTextContent("0")); + + fireEvent.wheel(screen.getByTestId("workspace"), { + bubbles: true, + cancelable: true, + deltaX: 20, + deltaY: 40, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + callbacks[0]?.(0); + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(2); + expect(screen.getByTestId("view-x")).toHaveTextContent("0"); + + callbacks[1]?.(140); + + await waitFor(() => { + const x = Number(screen.getByTestId("view-x").textContent); + const y = Number(screen.getByTestId("view-y").textContent); + expect(x).toBeLessThan(0); + expect(x).toBeGreaterThan(-20); + expect(y).toBeLessThan(0); + expect(y).toBeGreaterThan(-40); + }); + + callbacks[2]?.(280); + + await waitFor(() => { + expect(screen.getByTestId("view-x")).toHaveTextContent("-20"); + expect(screen.getByTestId("view-y")).toHaveTextContent("-40"); + }); + }); + + it("keeps low free-workspace pan smoothness close to immediate movement", async () => { + const callbacks: FrameRequestCallback[] = []; + vi.spyOn(performance, "now").mockReturnValue(0); + vi.spyOn(window, "requestAnimationFrame").mockImplementation( + (callback: FrameRequestCallback): number => { + callbacks.push(callback); + return callbacks.length; + }, + ); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); + vi.spyOn(window, "matchMedia").mockImplementation( + () => + ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) as unknown as MediaQueryList, + ); + window.localStorage.setItem(WORKSPACE_STORAGE_KEY, JSON.stringify([appWindow()])); + render(); + mockWorkspaceRect(); + + await waitFor(() => expect(screen.getByTestId("view-x")).toHaveTextContent("0")); + + fireEvent.wheel(screen.getByTestId("workspace"), { + bubbles: true, + cancelable: true, + deltaX: 20, + deltaY: 40, + }); + + callbacks[0]?.(0); + callbacks[1]?.(16); + + await waitFor(() => { + expect(screen.getByTestId("view-x")).toHaveTextContent("-20"); + expect(screen.getByTestId("view-y")).toHaveTextContent("-40"); + }); + }); + + it("settles workspace camera animation before routing Ctrl wheel inside a window", async () => { + const callbacks: FrameRequestCallback[] = []; + vi.spyOn(performance, "now").mockReturnValue(0); + vi.spyOn(window, "requestAnimationFrame").mockImplementation( + (callback: FrameRequestCallback): number => { + callbacks.push(callback); + return callbacks.length; + }, + ); + const cancelAnimationFrameSpy = vi + .spyOn(window, "cancelAnimationFrame") + .mockImplementation(() => undefined); + vi.spyOn(window, "matchMedia").mockImplementation( + () => + ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) as unknown as MediaQueryList, + ); + window.localStorage.setItem(WORKSPACE_STORAGE_KEY, JSON.stringify([appWindow()])); + render(); + mockWorkspaceRect(); + + await waitFor(() => expect(screen.getByTestId("view-x")).toHaveTextContent("0")); + + fireEvent.wheel(screen.getByTestId("workspace"), { + bubbles: true, + cancelable: true, + deltaX: 20, + deltaY: 40, + }); + callbacks[0]?.(0); + + fireEvent.wheel(screen.getByTestId("window-target"), { + bubbles: true, + cancelable: true, + clientX: 200, + clientY: 200, + ctrlKey: true, + deltaY: -100, + }); + + expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(2); + await waitFor(() => { + expect(screen.getByTestId("files-zoom")).toHaveTextContent("1.2"); + expect(screen.getByTestId("view-x")).toHaveTextContent("-20"); + expect(screen.getByTestId("view-y")).toHaveTextContent("-40"); + }); + }); + + it("uses immediate updates for smooth mode when reduced motion is requested", async () => { + const callbacks: FrameRequestCallback[] = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation( + (callback: FrameRequestCallback): number => { + callbacks.push(callback); + return callbacks.length; + }, + ); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); + vi.spyOn(window, "matchMedia").mockImplementation( + () => + ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) as unknown as MediaQueryList, + ); + window.localStorage.setItem(WORKSPACE_STORAGE_KEY, JSON.stringify([appWindow()])); + render(); + mockWorkspaceRect(); + + await waitFor(() => expect(screen.getByTestId("view-x")).toHaveTextContent("0")); + + fireEvent.wheel(screen.getByTestId("workspace"), { + bubbles: true, + cancelable: true, + deltaX: 20, + deltaY: 40, + }); + + callbacks[0]?.(0); + + await waitFor(() => { + expect(screen.getByTestId("view-x")).toHaveTextContent("-20"); + expect(screen.getByTestId("view-y")).toHaveTextContent("-40"); + }); + }); }); diff --git a/packages/keiko-ui/src/app/components/desktop/widgets/panels/SettingsPanel.test.tsx b/packages/keiko-ui/src/app/components/desktop/widgets/panels/SettingsPanel.test.tsx index 8352d5b6..6ccf600b 100644 --- a/packages/keiko-ui/src/app/components/desktop/widgets/panels/SettingsPanel.test.tsx +++ b/packages/keiko-ui/src/app/components/desktop/widgets/panels/SettingsPanel.test.tsx @@ -538,6 +538,26 @@ describe("SettingsPanel workspace wallpaper controls", () => { ); }); + it("persists workspace camera smoothness and emits the runtime event", async () => { + const listener = vi.fn(); + window.addEventListener("keiko:workspace-camera-smoothness", listener); + primeFetches([]); + render(); + + fireEvent.click(screen.getByRole("button", { name: "General" })); + fireEvent.change(screen.getByRole("slider", { name: "Workspace camera smoothness" }), { + target: { value: "72" }, + }); + + expect(window.localStorage.getItem("keiko.workspace.camera.smoothness")).toBe("72"); + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ detail: 72 }), + ); + expect(screen.getByRole("slider", { name: "Workspace camera smoothness" })).toHaveValue("72"); + + window.removeEventListener("keiko:workspace-camera-smoothness", listener); + }); + it("persists workspace border strength and applies the CSS variable", async () => { primeFetches([]); render(); diff --git a/packages/keiko-ui/src/app/components/desktop/widgets/panels/SettingsPanel.tsx b/packages/keiko-ui/src/app/components/desktop/widgets/panels/SettingsPanel.tsx index 8baaf6d9..fed1dd56 100644 --- a/packages/keiko-ui/src/app/components/desktop/widgets/panels/SettingsPanel.tsx +++ b/packages/keiko-ui/src/app/components/desktop/widgets/panels/SettingsPanel.tsx @@ -36,6 +36,8 @@ import { WORKSPACE_BACKGROUND_BRIGHTNESS_KEY, WORKSPACE_GRID_STRENGTH_EVENT, WORKSPACE_GRID_STRENGTH_KEY, + WORKSPACE_CAMERA_SMOOTHNESS_EVENT, + WORKSPACE_CAMERA_SMOOTHNESS_KEY, applyFrameBorderStrength, applyWorkspaceBackgroundBrightness, applyWorkspaceGridStrength, @@ -44,6 +46,7 @@ import { readWallpaperEnabled, readWallpaperOpacity, readWorkspaceBackgroundBrightness, + readWorkspaceCameraSmoothness, readWorkspaceGridStrength, } from "../../workspace-appearance"; @@ -469,6 +472,7 @@ function GeneralPrefs(): ReactNode { const [wp, setWp] = useState(readWallpaperOpacity); const [bgBrightness, setBgBrightness] = useState(readWorkspaceBackgroundBrightness); const [gridStrength, setGridStrength] = useState(readWorkspaceGridStrength); + const [cameraSmoothness, setCameraSmoothness] = useState(readWorkspaceCameraSmoothness); const [frameBorderStrength, setFrameBorderStrength] = useState(readFrameBorderStrength); const [frameInnerGlowStrength, setFrameInnerGlowStrength] = useState( readFrameInnerGlowStrength, @@ -518,6 +522,18 @@ function GeneralPrefs(): ReactNode { window.dispatchEvent(new CustomEvent(WORKSPACE_GRID_STRENGTH_EVENT, { detail: gridStrength })); }, [gridStrength]); + useEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(WORKSPACE_CAMERA_SMOOTHNESS_KEY, String(cameraSmoothness)); + } catch { + /* ignore quota / private mode */ + } + window.dispatchEvent( + new CustomEvent(WORKSPACE_CAMERA_SMOOTHNESS_EVENT, { detail: cameraSmoothness }), + ); + }, [cameraSmoothness]); + useEffect(() => { if (typeof window === "undefined") return; try { @@ -548,6 +564,9 @@ function GeneralPrefs(): ReactNode { const fill: CSSProperties = { ["--p"]: `${String(wp)}%` } as CSSProperties; const bgFill: CSSProperties = { ["--p"]: `${String(bgBrightness)}%` } as CSSProperties; const gridFill: CSSProperties = { ["--p"]: `${String(gridStrength)}%` } as CSSProperties; + const cameraSmoothnessFill: CSSProperties = { + ["--p"]: `${String(cameraSmoothness)}%`, + } as CSSProperties; const frameBorderFill: CSSProperties = { ["--p"]: `${String(frameBorderStrength)}%`, } as CSSProperties; @@ -685,6 +704,31 @@ function GeneralPrefs(): ReactNode { {t("settings.scale.strong")} +
+
+ + {cameraSmoothness}% +
+ setCameraSmoothness(Number.parseInt(e.target.value, 10))} + style={cameraSmoothnessFill} + aria-label={t("settings.workspace.cameraAnimation")} + /> +
+ {t("settings.workspace.cameraAnimationMinimal")} + {t("settings.workspace.cameraAnimationSmooth")} +
+
{t("settings.workspace.cameraAnimationHelp")}
+