From b85c60f996be4fb0567754e9c5cfb1037191492e Mon Sep 17 00:00:00 2001 From: HendrikD2005 <144935017+HendrikD2005@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:47:47 +0200 Subject: [PATCH 1/8] add workspace camera animation mode --- .../src/app/components/desktop/AppShell.tsx | 20 ++++ .../components/desktop/hooks/useWorkspace.ts | 108 +++++++++++++++++- .../desktop/hooks/useWorkspace.wheel.test.tsx | 102 ++++++++++++++++- .../widgets/panels/SettingsPanel.test.tsx | 21 ++++ .../desktop/widgets/panels/SettingsPanel.tsx | 42 +++++++ .../desktop/workspace-appearance.ts | 16 +++ packages/keiko-ui/src/lib/i18n.tsx | 10 ++ 7 files changed, 313 insertions(+), 6 deletions(-) diff --git a/packages/keiko-ui/src/app/components/desktop/AppShell.tsx b/packages/keiko-ui/src/app/components/desktop/AppShell.tsx index 020018ced..972c65ee0 100644 --- a/packages/keiko-ui/src/app/components/desktop/AppShell.tsx +++ b/packages/keiko-ui/src/app/components/desktop/AppShell.tsx @@ -13,6 +13,11 @@ import { Header, type HeaderStatusTone } from "./Header"; import { LeftRail } from "./LeftRail"; import { RightRail } from "./RightRail"; import { Workspace } from "./Workspace"; +import { + readWorkspaceCameraAnimationMode, + WORKSPACE_CAMERA_ANIMATION_MODE_EVENT, + type WorkspaceCameraAnimationMode, +} from "./workspace-appearance"; import { CommandPalette, type Command } from "./modals/CommandPalette"; import { GatewaySetupDialog } from "./modals/GatewaySetupDialog"; import { NewWindowDialog } from "./modals/NewWindowDialog"; @@ -537,7 +542,22 @@ function AppShellInner(): ReactNode { }, [chatForWindow, session], ); + const [cameraAnimationMode, setCameraAnimationMode] = + useState(readWorkspaceCameraAnimationMode); + + useEffect(() => { + const onCameraAnimationMode = (event: Event): void => { + const detail = (event as CustomEvent).detail; + setCameraAnimationMode(detail === "smooth" ? "smooth" : "minimal"); + }; + window.addEventListener(WORKSPACE_CAMERA_ANIMATION_MODE_EVENT, onCameraAnimationMode); + return () => { + window.removeEventListener(WORKSPACE_CAMERA_ANIMATION_MODE_EVENT, onCameraAnimationMode); + }; + }, []); + const ws = useWorkspace(wsRef, { + cameraAnimationMode, 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 fa8abe877..e973136dc 100644 --- a/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts +++ b/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts @@ -35,6 +35,7 @@ import { makeSnapActions, } from "./workspaceActions"; import type { ChatConnectedScope, ChatLocalKnowledgeScope } from "@/lib/types"; +import type { WorkspaceCameraAnimationMode } from "../workspace-appearance"; export type { AppWindow, Connection, ConnectingState, SnapPrev, View }; export type { SnapZone } from "../windows/connectionUtils"; @@ -51,6 +52,7 @@ export const MIN_ZOOM = 0.3; export const MAX_ZOOM = 2.5; const CONTENT_MIN_ZOOM = 0.5; const CONTENT_MAX_ZOOM = 2; +const CAMERA_ANIMATION_DURATION_MS = 180; function readView(): View { if (typeof window === "undefined") return { zoom: 1, x: 0, y: 0 }; @@ -252,6 +254,7 @@ function windowIdFromWheelTarget(target: EventTarget | null): string | null { interface UsePanZoomArgs { readonly wsRef: RefObject; readonly view: View; + readonly cameraAnimationMode: WorkspaceCameraAnimationMode; readonly winsRef: MutableRefObject; readonly setView: Dispatch>; readonly setWins: Dispatch>; @@ -306,11 +309,41 @@ 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, + cameraAnimationMode, + winsRef, + setView, + setWins, +}: UsePanZoomArgs): PanZoomResult { const viewRef = useRef(view); viewRef.current = view; + const renderedViewRef = useRef(view); + renderedViewRef.current = view; const pendingViewRef = useRef(null); 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,10 +379,69 @@ 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): void => { + if ( + cameraAnimationMode !== "smooth" || + prefersReducedCameraMotion() || + typeof window.requestAnimationFrame !== "function" || + typeof window.cancelAnimationFrame !== "function" + ) { + if (animationFrameRef.current !== null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + setView(target); + return; + } + + 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) / CAMERA_ANIMATION_DURATION_MS), + ), + ); + 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) / CAMERA_ANIMATION_DURATION_MS), + ); + 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); + }, + [cameraAnimationMode, setView], + ); + const queueView = useCallback( (next: View | ((current: View) => View)): void => { const base = pendingViewRef.current ?? viewRef.current; @@ -372,10 +464,10 @@ function usePanZoom({ wsRef, view, winsRef, setView, setWins }: UsePanZoomArgs): frameRef.current = null; const pending = pendingViewRef.current; pendingViewRef.current = null; - if (pending !== null) setView(pending); + if (pending !== null) animateView(pending); }); }, - [scheduleViewPersist, setView], + [animateView, scheduleViewPersist, setView], ); useEffect(() => { @@ -924,6 +1016,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 cameraAnimationMode?: WorkspaceCameraAnimationMode | undefined; readonly onScopeBind?: ((chatWindowId: string, scope: ChatConnectedScope) => boolean | Promise) | undefined; readonly onScopeUnbind?: ((chatWindowId: string, scope: ChatConnectedScope) => void) | undefined; @@ -948,7 +1041,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 { + cameraAnimationMode = "minimal", + onScopeBind, + onScopeUnbind, + onConnectorBind, + onConnectorUnbind, + } = opts; const zc = useRef(3); const snapZone = useRef(null); const suppressNextServerPersistRef = useRef(false); @@ -998,6 +1097,7 @@ export function useWorkspace( const { viewRef, worldVP, zoomTo, fitView, resetView, panBy, rect } = usePanZoom({ wsRef, view, + cameraAnimationMode, 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 04a729244..b5e62c38f 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 @@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re import { afterEach, describe, expect, it, vi } from "vitest"; import { useWorkspace } from "./useWorkspace"; import type { AppWindow } from "../windows/types"; +import type { WorkspaceCameraAnimationMode } from "../workspace-appearance"; const WORKSPACE_STORAGE_KEY = "keiko.workspace.v4"; @@ -23,9 +24,13 @@ function appWindow(patch: Partial = {}): AppWindow { }; } -function Harness(): ReactElement { +function Harness({ + cameraAnimationMode = "minimal", +}: { + readonly cameraAnimationMode?: WorkspaceCameraAnimationMode; +}): ReactElement { const wsRef = useRef(null); - const ws = useWorkspace(wsRef); + const ws = useWorkspace(wsRef, { cameraAnimationMode }); const files = ws.wins?.find((win) => win.id === "files-1"); return ( @@ -168,4 +173,97 @@ 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]?.(90); + + 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]?.(180); + + await waitFor(() => { + 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 8352d5b61..0b97828a9 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,27 @@ describe("SettingsPanel workspace wallpaper controls", () => { ); }); + it("persists the workspace camera animation mode and emits the runtime event", async () => { + const listener = vi.fn(); + window.addEventListener("keiko:workspace-camera-animation-mode", listener); + primeFetches([]); + render(); + + fireEvent.click(screen.getByRole("button", { name: "General" })); + fireEvent.click(screen.getByRole("button", { name: "Smooth" })); + + expect(window.localStorage.getItem("keiko.workspace.camera.animationMode")).toBe("smooth"); + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ detail: "smooth" }), + ); + expect(screen.getByRole("button", { name: "Smooth" })).toHaveAttribute( + "aria-pressed", + "true", + ); + + window.removeEventListener("keiko:workspace-camera-animation-mode", 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 8baaf6d99..83d609857 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_ANIMATION_MODE_EVENT, + WORKSPACE_CAMERA_ANIMATION_MODE_KEY, applyFrameBorderStrength, applyWorkspaceBackgroundBrightness, applyWorkspaceGridStrength, @@ -44,7 +46,9 @@ import { readWallpaperEnabled, readWallpaperOpacity, readWorkspaceBackgroundBrightness, + readWorkspaceCameraAnimationMode, readWorkspaceGridStrength, + type WorkspaceCameraAnimationMode, } from "../../workspace-appearance"; function kindLabel(kind: ModelCapability["kind"]): string { @@ -469,6 +473,8 @@ function GeneralPrefs(): ReactNode { const [wp, setWp] = useState(readWallpaperOpacity); const [bgBrightness, setBgBrightness] = useState(readWorkspaceBackgroundBrightness); const [gridStrength, setGridStrength] = useState(readWorkspaceGridStrength); + const [cameraAnimationMode, setCameraAnimationMode] = + useState(readWorkspaceCameraAnimationMode); const [frameBorderStrength, setFrameBorderStrength] = useState(readFrameBorderStrength); const [frameInnerGlowStrength, setFrameInnerGlowStrength] = useState( readFrameInnerGlowStrength, @@ -518,6 +524,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_ANIMATION_MODE_KEY, cameraAnimationMode); + } catch { + /* ignore quota / private mode */ + } + window.dispatchEvent( + new CustomEvent(WORKSPACE_CAMERA_ANIMATION_MODE_EVENT, { detail: cameraAnimationMode }), + ); + }, [cameraAnimationMode]); + useEffect(() => { if (typeof window === "undefined") return; try { @@ -685,6 +703,30 @@ function GeneralPrefs(): ReactNode { {t("settings.scale.strong")} +
+
+ {t("settings.workspace.cameraAnimation")} +
+ + +
+
+
{t("settings.workspace.cameraAnimationHelp")}
+
- {t("settings.workspace.cameraAnimation")} -
- - -
+ + {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")}
diff --git a/packages/keiko-ui/src/app/components/desktop/workspace-appearance.ts b/packages/keiko-ui/src/app/components/desktop/workspace-appearance.ts index 4c3809ee7..48d557fb6 100644 --- a/packages/keiko-ui/src/app/components/desktop/workspace-appearance.ts +++ b/packages/keiko-ui/src/app/components/desktop/workspace-appearance.ts @@ -4,7 +4,7 @@ export const WORKSPACE_BACKGROUND_BRIGHTNESS_KEY = "keiko.workspace.background.b export const WORKSPACE_GRID_STRENGTH_KEY = "keiko.workspace.grid.strength"; export const FRAME_BORDER_STRENGTH_KEY = "keiko.frame.border.strength"; export const FRAME_INNER_GLOW_STRENGTH_KEY = "keiko.frame.inner.glow.strength"; -export const WORKSPACE_CAMERA_ANIMATION_MODE_KEY = "keiko.workspace.camera.animationMode"; +export const WORKSPACE_CAMERA_SMOOTHNESS_KEY = "keiko.workspace.camera.smoothness"; export const DEFAULT_WALLPAPER_ENABLED = false; @@ -14,9 +14,9 @@ export const WORKSPACE_BACKGROUND_BRIGHTNESS_EVENT = "keiko:workspace-background export const WORKSPACE_GRID_STRENGTH_EVENT = "keiko:workspace-grid-strength"; export const FRAME_BORDER_STRENGTH_EVENT = "keiko:frame-border-strength"; export const FRAME_INNER_GLOW_STRENGTH_EVENT = "keiko:frame-inner-glow-strength"; -export const WORKSPACE_CAMERA_ANIMATION_MODE_EVENT = "keiko:workspace-camera-animation-mode"; +export const WORKSPACE_CAMERA_SMOOTHNESS_EVENT = "keiko:workspace-camera-smoothness"; -export type WorkspaceCameraAnimationMode = "minimal" | "smooth"; +export const DEFAULT_WORKSPACE_CAMERA_SMOOTHNESS = 0; export function clampPercent(value: number): number { if (!Number.isFinite(value)) return 0; @@ -93,15 +93,17 @@ export function readFrameInnerGlowStrength(): number { } } -export function readWorkspaceCameraAnimationMode(): WorkspaceCameraAnimationMode { - if (typeof window === "undefined") return "minimal"; +export function readWorkspaceCameraSmoothness(): number { + if (typeof window === "undefined") return DEFAULT_WORKSPACE_CAMERA_SMOOTHNESS; try { - if (typeof window.localStorage?.getItem !== "function") return "minimal"; - return window.localStorage.getItem(WORKSPACE_CAMERA_ANIMATION_MODE_KEY) === "smooth" - ? "smooth" - : "minimal"; + if (typeof window.localStorage?.getItem !== "function") + return DEFAULT_WORKSPACE_CAMERA_SMOOTHNESS; + const raw = window.localStorage.getItem(WORKSPACE_CAMERA_SMOOTHNESS_KEY); + if (raw !== null) return clampPercent(Number.parseInt(raw, 10)); + const legacy = window.localStorage.getItem("keiko.workspace.camera.animationMode"); + return legacy === "smooth" ? 100 : DEFAULT_WORKSPACE_CAMERA_SMOOTHNESS; } catch { - return "minimal"; + return DEFAULT_WORKSPACE_CAMERA_SMOOTHNESS; } } diff --git a/packages/keiko-ui/src/lib/i18n.tsx b/packages/keiko-ui/src/lib/i18n.tsx index 9bf70410e..bf53a17ee 100644 --- a/packages/keiko-ui/src/lib/i18n.tsx +++ b/packages/keiko-ui/src/lib/i18n.tsx @@ -241,11 +241,11 @@ const EN_MESSAGES = { "settings.scale.strong": "Strong", "settings.workspace.backgroundBrightness": "Workspace background brightness", "settings.workspace.gridStrength": "Workspace grid strength", - "settings.workspace.cameraAnimation": "Workspace camera animation", + "settings.workspace.cameraAnimation": "Workspace camera smoothness", "settings.workspace.cameraAnimationMinimal": "Minimal", "settings.workspace.cameraAnimationSmooth": "Smooth", "settings.workspace.cameraAnimationHelp": - "Minimal applies pan and zoom immediately. Smooth eases the workspace camera toward the target view.", + "Move right to make pan and zoom transitions softer. Minimal applies changes immediately.", "settings.workspace.borderStrength": "Workspace border strength", "settings.workspace.innerGlow": "Workspace inner glow", "settings.models.gatewayTitle": "Model gateway", @@ -550,11 +550,11 @@ const DE_MESSAGES: Record = { "settings.scale.strong": "Stark", "settings.workspace.backgroundBrightness": "Arbeitsbereich-Helligkeit", "settings.workspace.gridStrength": "Rasterstaerke", - "settings.workspace.cameraAnimation": "Workspace-Kameraanimation", + "settings.workspace.cameraAnimation": "Workspace-Kamera-Sanftheit", "settings.workspace.cameraAnimationMinimal": "Minimal", "settings.workspace.cameraAnimationSmooth": "Sanft", "settings.workspace.cameraAnimationHelp": - "Minimal wendet Schwenken und Zoom direkt an. Sanft bewegt die Workspace-Kamera weich zur Zielansicht.", + "Je weiter rechts, desto sanfter werden Schwenken und Zoom. Minimal wendet Aenderungen direkt an.", "settings.workspace.borderStrength": "Rahmenstaerke", "settings.workspace.innerGlow": "Inneres Leuchten", "settings.models.gatewayTitle": "Modell-Gateway", From 04ad14e50d222813a6e00cab2b4fcf7926ba8335 Mon Sep 17 00:00:00 2001 From: HendrikD2005 <144935017+HendrikD2005@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:22:26 +0200 Subject: [PATCH 3/8] fix workspace content zoom during smooth camera animation --- .../components/desktop/hooks/useWorkspace.ts | 15 +- .../desktop/hooks/useWorkspace.wheel.test.tsx | 51 +++++ .../desktop/windows/WindowFrame.test.tsx | 13 +- .../desktop/windows/WindowFrame.tsx | 191 +++++++++--------- packages/keiko-ui/src/app/globals.css | 5 + 5 files changed, 181 insertions(+), 94 deletions(-) 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 30007a38d..af3fac8dc 100644 --- a/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts +++ b/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts @@ -447,6 +447,18 @@ function usePanZoom({ [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 => { const base = pendingViewRef.current ?? viewRef.current; @@ -483,6 +495,7 @@ function usePanZoom({ e.preventDefault(); const windowId = windowIdFromWheelTarget(e.target); if (windowId !== null) { + settleCameraAnimation(); setWins((ws) => ws === null ? ws @@ -515,7 +528,7 @@ function usePanZoom({ return () => { el.removeEventListener("wheel", onWheel); }; - }, [wsRef, setWins, queueView]); + }, [wsRef, setWins, queueView, settleCameraAnimation]); const rect = useCallback( (): DOMRect | null => (wsRef.current === null ? null : wsRef.current.getBoundingClientRect()), 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 419a98db9..be9be932c 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 @@ -228,6 +228,57 @@ describe("useWorkspace wheel zoom routing", () => { }); }); + 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( diff --git a/packages/keiko-ui/src/app/components/desktop/windows/WindowFrame.test.tsx b/packages/keiko-ui/src/app/components/desktop/windows/WindowFrame.test.tsx index 063bc7cb9..9fdc49940 100644 --- a/packages/keiko-ui/src/app/components/desktop/windows/WindowFrame.test.tsx +++ b/packages/keiko-ui/src/app/components/desktop/windows/WindowFrame.test.tsx @@ -327,7 +327,7 @@ describe("WindowFrame content zoom controls", () => { ).toBeInTheDocument(); }); - it("scales the whole window chrome with content zoom while preserving visual geometry", () => { + it("scales window chrome inside a stable outer workspace box", () => { const { container } = render( { ); const windowSection = container.querySelector(".window"); + const contentZoom = container.querySelector(".win-content-zoom"); const body = container.querySelector(".win-body"); expect(windowSection).not.toBeNull(); + expect(contentZoom).not.toBeNull(); expect(body).not.toBeNull(); - expect(Number.parseFloat(windowSection?.style.width ?? "")).toBeCloseTo(500, 5); expect(windowSection).toHaveStyle({ - height: "300px", left: "40px", top: "40px", + width: "700px", + height: "420px", + }); + expect(windowSection?.style.zoom).toBe(""); + expect(contentZoom).toHaveStyle({ + width: "500px", + height: "300px", transform: "scale(1.4)", transformOrigin: "0 0", }); diff --git a/packages/keiko-ui/src/app/components/desktop/windows/WindowFrame.tsx b/packages/keiko-ui/src/app/components/desktop/windows/WindowFrame.tsx index 3c07c532d..d83f60856 100644 --- a/packages/keiko-ui/src/app/components/desktop/windows/WindowFrame.tsx +++ b/packages/keiko-ui/src/app/components/desktop/windows/WindowFrame.tsx @@ -535,8 +535,8 @@ function WindowFrameImpl({ // eslint-disable-next-line react-hooks/exhaustive-deps -- linkRevision is the cross-window invalidation signal [api, win.type, win.id, linkRevision], ); - const ew = win.w / zoom; - const eh = win.h / zoom; + const ew = Math.round((win.w / zoom) * 1000) / 1000; + const eh = Math.round((win.h / zoom) * 1000) / 1000; const updateCfg = useCallback( (patch: AppWindow["cfg"]): void => { api.update(win.id, { cfg: { ...win.cfg, ...patch } }); @@ -909,13 +909,20 @@ function WindowFrameImpl({ () => ({ left: win.x, top: win.y, + width: win.w, + height: win.h, + zIndex: win.z, + }), + [win.x, win.y, win.w, win.h, win.z], + ); + const contentZoomStyle = useMemo( + () => ({ width: ew, height: eh, - zIndex: win.z, transform: `scale(${String(zoom)})`, transformOrigin: "0 0", }), - [win.x, win.y, ew, eh, win.z, zoom], + [ew, eh, zoom], ); return ( @@ -946,123 +953,127 @@ function WindowFrameImpl({ if (!top) window.setTimeout(() => api.focus(win.id), 0); }} > - {/* Header is a drag surface; keyboard equivalent is ⌘+Arrows handled by useKeyboardCtrls. */} - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} -
{ - if (shouldMaximizeFromHeaderDoubleClick(e)) api.maximize(win.id); - }} - > - + {/* Header is a drag surface; keyboard equivalent is ⌘+Arrows handled by useKeyboardCtrls. */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
{ + if (shouldMaximizeFromHeaderDoubleClick(e)) api.maximize(win.id); + }} > - - - {def.title} - {/* Audit C159 — the badge ellipsizes at 150px; title= keeps the full - path/URL reachable for mouse users. */} - {sub !== null ? ( - - {sub} + + - ) : null} - - {/* Audit C297 — every window carried word-identical control labels; with + {def.title} + {/* Audit C159 — the badge ellipsizes at 150px; title= keeps the full + path/URL reachable for mouse users. */} + {sub !== null ? ( + + {sub} + + ) : null} + + {/* Audit C297 — every window carried word-identical control labels; with several windows open, screen-reader and voice-control users could not tell WHICH window a Close/Zoom/Connect control acts on (WCAG 2.4.6). def.title scopes each label; the visible chrome is unchanged. */} - {showHeaderZoom ? ( -
+ {showHeaderZoom ? ( +
+ + + +
+ ) : null} +
- ) : null} -
- - - +
+
+ {body}
-
-
- {body}
{!win.max ? HANDLES.map((d: Handle) => ( diff --git a/packages/keiko-ui/src/app/globals.css b/packages/keiko-ui/src/app/globals.css index de44e025f..fb64f4849 100644 --- a/packages/keiko-ui/src/app/globals.css +++ b/packages/keiko-ui/src/app/globals.css @@ -3766,6 +3766,11 @@ html[data-keiko-modal-open="true"] .workspace[data-panning="true"] { .window[data-max="true"] { border-radius: 0; } +.win-content-zoom { + display: flex; + flex-direction: column; + flex: none; +} .win-head { display: flex; align-items: center; From 32129e48aeef2febda954f52156be8a4867b0c5b Mon Sep 17 00:00:00 2001 From: HendrikD2005 <144935017+HendrikD2005@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:49:09 +0200 Subject: [PATCH 4/8] tune workspace pan smoothness --- .../components/desktop/hooks/useWorkspace.ts | 33 +++++++++--- .../desktop/hooks/useWorkspace.wheel.test.tsx | 51 +++++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) 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 af3fac8dc..881172705 100644 --- a/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts +++ b/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts @@ -53,6 +53,7 @@ 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 = 5; function readView(): View { if (typeof window === "undefined") return { zoom: 1, x: 0, y: 0 }; @@ -260,6 +261,10 @@ interface UsePanZoomArgs { readonly setWins: Dispatch>; } +interface QueueViewOptions { + readonly smoothnessScale?: number; +} + interface PanZoomResult { readonly viewRef: MutableRefObject; readonly worldVP: () => ViewportWorld | null; @@ -339,6 +344,7 @@ function usePanZoom({ const renderedViewRef = useRef(view); renderedViewRef.current = view; const pendingViewRef = useRef(null); + const pendingViewSmoothnessScaleRef = useRef(1); const frameRef = useRef(null); const animationFrameRef = useRef(null); const animationStartRef = useRef(view); @@ -390,9 +396,13 @@ function usePanZoom({ ); const animateView = useCallback( - (target: View): void => { + (target: View, smoothnessScale = 1): void => { + const effectiveSmoothness = Math.min( + 100, + Math.max(0, cameraSmoothness * smoothnessScale), + ); if ( - cameraSmoothness <= 0 || + effectiveSmoothness <= 0 || prefersReducedCameraMotion() || typeof window.requestAnimationFrame !== "function" || typeof window.cancelAnimationFrame !== "function" @@ -408,7 +418,7 @@ function usePanZoom({ const durationMs = MIN_CAMERA_ANIMATION_DURATION_MS + ((MAX_CAMERA_ANIMATION_DURATION_MS - MIN_CAMERA_ANIMATION_DURATION_MS) * - Math.min(100, Math.max(0, cameraSmoothness))) / + effectiveSmoothness) / 100; const now = typeof performance !== "undefined" && typeof performance.now === "function" @@ -460,11 +470,13 @@ function usePanZoom({ }, [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; viewRef.current = resolved; pendingViewRef.current = resolved; + pendingViewSmoothnessScaleRef.current = smoothnessScale; scheduleViewPersist(); if ( @@ -480,8 +492,10 @@ function usePanZoom({ frameRef.current = window.requestAnimationFrame(() => { frameRef.current = null; const pending = pendingViewRef.current; + const pendingSmoothnessScale = pendingViewSmoothnessScaleRef.current; pendingViewRef.current = null; - if (pending !== null) animateView(pending); + pendingViewSmoothnessScaleRef.current = 1; + if (pending !== null) animateView(pending, pendingSmoothnessScale); }); }, [animateView, scheduleViewPersist, setView], @@ -522,7 +536,9 @@ function usePanZoom({ } 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 }), { + smoothnessScale: PAN_CAMERA_SMOOTHNESS_SCALE, + }); }; el.addEventListener("wheel", onWheel, { passive: false }); return () => { @@ -565,7 +581,10 @@ function usePanZoom({ 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 }), { + smoothnessScale: PAN_CAMERA_SMOOTHNESS_SCALE, + }), [queueView], ); 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 be9be932c..382e2c3f1 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 @@ -228,6 +228,57 @@ describe("useWorkspace wheel zoom routing", () => { }); }); + it("maps 20 percent smoothness to the maximum free-workspace pan easing", 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]?.(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("settles workspace camera animation before routing Ctrl wheel inside a window", async () => { const callbacks: FrameRequestCallback[] = []; vi.spyOn(performance, "now").mockReturnValue(0); From d95334a575feab771727cb0bf5702bc6b293a5cf Mon Sep 17 00:00:00 2001 From: HendrikD2005 <144935017+HendrikD2005@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:53:28 +0200 Subject: [PATCH 5/8] adjust workspace pan smoothness ratio --- .../app/components/desktop/hooks/useWorkspace.ts | 2 +- .../desktop/hooks/useWorkspace.wheel.test.tsx | 15 ++------------- 2 files changed, 3 insertions(+), 14 deletions(-) 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 881172705..0eed2c2ad 100644 --- a/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts +++ b/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts @@ -53,7 +53,7 @@ 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 = 5; +const PAN_CAMERA_SMOOTHNESS_SCALE = 1; function readView(): View { if (typeof window === "undefined") return { zoom: 1, x: 0, y: 0 }; 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 382e2c3f1..8618541b8 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 @@ -228,7 +228,7 @@ describe("useWorkspace wheel zoom routing", () => { }); }); - it("maps 20 percent smoothness to the maximum free-workspace pan easing", async () => { + 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( @@ -247,7 +247,7 @@ describe("useWorkspace wheel zoom routing", () => { }) as unknown as MediaQueryList, ); window.localStorage.setItem(WORKSPACE_STORAGE_KEY, JSON.stringify([appWindow()])); - render(); + render(); mockWorkspaceRect(); await waitFor(() => expect(screen.getByTestId("view-x")).toHaveTextContent("0")); @@ -262,17 +262,6 @@ describe("useWorkspace wheel zoom routing", () => { callbacks[0]?.(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"); From 40e692cc12ccdbab9abd6fb07d3a3603134d585d Mon Sep 17 00:00:00 2001 From: HendrikD2005 <144935017+HendrikD2005@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:58:18 +0200 Subject: [PATCH 6/8] reduce workspace pan smoothness intensity --- .../keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0eed2c2ad..2ede221f8 100644 --- a/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts +++ b/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts @@ -53,7 +53,7 @@ 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 = 1; +const PAN_CAMERA_SMOOTHNESS_SCALE = 0.65; function readView(): View { if (typeof window === "undefined") return { zoom: 1, x: 0, y: 0 }; From 6de0b977f2aaa731f24fd4904bf023cb4b8d730a Mon Sep 17 00:00:00 2001 From: HendrikD2005 <144935017+HendrikD2005@users.noreply.github.com> Date: Sun, 28 Jun 2026 04:25:55 +0200 Subject: [PATCH 7/8] fix workspace pan smoothness floor --- .../components/desktop/hooks/useWorkspace.ts | 18 ++++++++++++------ .../desktop/hooks/useWorkspace.wheel.test.tsx | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) 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 2ede221f8..103bc363c 100644 --- a/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts +++ b/packages/keiko-ui/src/app/components/desktop/hooks/useWorkspace.ts @@ -263,6 +263,7 @@ interface UsePanZoomArgs { interface QueueViewOptions { readonly smoothnessScale?: number; + readonly minDurationMs?: number; } interface PanZoomResult { @@ -345,6 +346,7 @@ function usePanZoom({ 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); @@ -396,7 +398,7 @@ function usePanZoom({ ); const animateView = useCallback( - (target: View, smoothnessScale = 1): void => { + (target: View, smoothnessScale = 1, minDurationMs = MIN_CAMERA_ANIMATION_DURATION_MS): void => { const effectiveSmoothness = Math.min( 100, Math.max(0, cameraSmoothness * smoothnessScale), @@ -416,10 +418,8 @@ function usePanZoom({ } const durationMs = - MIN_CAMERA_ANIMATION_DURATION_MS + - ((MAX_CAMERA_ANIMATION_DURATION_MS - MIN_CAMERA_ANIMATION_DURATION_MS) * - effectiveSmoothness) / - 100; + minDurationMs + + ((MAX_CAMERA_ANIMATION_DURATION_MS - minDurationMs) * effectiveSmoothness) / 100; const now = typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() @@ -474,9 +474,11 @@ function usePanZoom({ 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 ( @@ -493,9 +495,11 @@ function usePanZoom({ frameRef.current = null; const pending = pendingViewRef.current; const pendingSmoothnessScale = pendingViewSmoothnessScaleRef.current; + const pendingMinDurationMs = pendingViewMinDurationRef.current; pendingViewRef.current = null; pendingViewSmoothnessScaleRef.current = 1; - if (pending !== null) animateView(pending, pendingSmoothnessScale); + pendingViewMinDurationRef.current = MIN_CAMERA_ANIMATION_DURATION_MS; + if (pending !== null) animateView(pending, pendingSmoothnessScale, pendingMinDurationMs); }); }, [animateView, scheduleViewPersist, setView], @@ -537,6 +541,7 @@ function usePanZoom({ e.preventDefault(); const delta = normalizeWheelDelta(e); queueView((v) => ({ ...v, x: v.x - delta.x, y: v.y - delta.y }), { + minDurationMs: 0, smoothnessScale: PAN_CAMERA_SMOOTHNESS_SCALE, }); }; @@ -583,6 +588,7 @@ function usePanZoom({ const panBy = useCallback( (dx: number, dy: number): void => queueView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }), { + minDurationMs: 0, smoothnessScale: PAN_CAMERA_SMOOTHNESS_SCALE, }), [queueView], 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 8618541b8..d3237f75e 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 @@ -260,7 +260,7 @@ describe("useWorkspace wheel zoom routing", () => { }); callbacks[0]?.(0); - callbacks[1]?.(140); + callbacks[1]?.(16); await waitFor(() => { expect(screen.getByTestId("view-x")).toHaveTextContent("-20"); From 9ea98d7050b943d2fc721b371e18b3baafc3538f Mon Sep 17 00:00:00 2001 From: HendrikD2005 <144935017+HendrikD2005@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:18:28 +0200 Subject: [PATCH 8/8] fix ui coverage css proof hashes --- docs/design-system/evidence/1300/a11y/a11y-proof.json | 4 ++-- .../evidence/1300/consolidated-fidelity-proof.json | 4 ++-- .../evidence/1300/editor/editor-fidelity-proof.json | 4 ++-- packages/keiko-ui/src/app/globals.css.test.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/design-system/evidence/1300/a11y/a11y-proof.json b/docs/design-system/evidence/1300/a11y/a11y-proof.json index c3e28ed95..bf4beeb63 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 94fca3f8d..2d17416e2 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 5c8372ff4..46625761f 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/globals.css.test.ts b/packages/keiko-ui/src/app/globals.css.test.ts index 0f652fe81..80a921a3d 100644 --- a/packages/keiko-ui/src/app/globals.css.test.ts +++ b/packages/keiko-ui/src/app/globals.css.test.ts @@ -19,7 +19,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; const here = dirname(fileURLToPath(import.meta.url)); -const css = readFileSync(resolve(here, "globals.css"), "utf8"); +const css = readFileSync(resolve(here, "globals.css"), "utf8").replace(/\r\n?/g, "\n"); const currentCssSha256 = createHash("sha256").update(css).digest("hex"); const evidenceHarness1297 = readFileSync( resolve(here, "../../../..", "docs/design-system/evidence/1297/equivalence-harness.mjs"),