diff --git a/apps/packages/ui/src/compositor-pulse.tsx b/apps/packages/ui/src/compositor-pulse.tsx index 391a0a1d7f..8b6e837137 100644 --- a/apps/packages/ui/src/compositor-pulse.tsx +++ b/apps/packages/ui/src/compositor-pulse.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { firstAnimationListValue, parseCssTime } from "./animation-utils"; +import { createPersistentMotionVisibility } from "./persistent-motion-visibility"; type CompositorPulseProps = React.ComponentProps<"span"> & { minimumOpacity?: number; @@ -17,9 +18,18 @@ function CompositorPulse({ useCompositorEffect(() => { const element = elementRef.current; - if (!element || typeof element.animate !== "function") return; + if (!element) return; const inlineAnimation = element.style.animation; + const visibility = createPersistentMotionVisibility(element); + const registration = visibility.register(element); + if (typeof element.animate !== "function") { + return () => { + registration.unregister(); + visibility.dispose(); + }; + } + let animation: Animation | null = null; let reducedMotionQuery: MediaQueryList | null = null; try { @@ -33,6 +43,7 @@ function CompositorPulse({ animation?.cancel(); animation = null; element.style.animation = inlineAnimation; + registration.setAnimation(null); }; const startAnimation = () => { @@ -62,6 +73,7 @@ function CompositorPulse({ } element.style.animation = "none"; + registration.setAnimation(animation); }; const handleMotionPreferenceChange = () => { @@ -75,6 +87,8 @@ function CompositorPulse({ return () => { reducedMotionQuery?.removeEventListener("change", handleMotionPreferenceChange); restoreAnimation(); + registration.unregister(); + visibility.dispose(); }; }, [minimumAtEndpoints, minimumOpacity, props.className]); diff --git a/apps/packages/ui/src/compositor-spin.tsx b/apps/packages/ui/src/compositor-spin.tsx index 77a660628c..f2f72f9a7a 100644 --- a/apps/packages/ui/src/compositor-spin.tsx +++ b/apps/packages/ui/src/compositor-spin.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { cn } from "./lib/utils"; +import { createPersistentMotionVisibility } from "./persistent-motion-visibility"; const DEFAULT_DURATION_MS = 1_000; const useCompositorEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect; @@ -14,25 +15,50 @@ function CompositorSpin({ className, style, ...props }: React.ComponentProps<"sp useCompositorEffect(() => { const element = elementRef.current; - if (!element || typeof element.animate !== "function") return; + if (!element) return; + const visibility = createPersistentMotionVisibility(element); + const registration = visibility.register(element); + if (typeof element.animate !== "function") { + return () => { + registration.unregister(); + visibility.dispose(); + }; + } + + const inlineAnimation = element.style.animation; + const inlineTransform = element.style.transform; element.style.removeProperty("animation"); const computedStyle = window.getComputedStyle(element); const duration = parseAnimationDuration(computedStyle.animationDuration); element.style.animation = "none"; void window.getComputedStyle(element).animationName; element.style.transform = "translateZ(0)"; - const animation = element.animate( - [{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }], - { - duration, - easing: "linear", - iterations: Infinity, - }, - ); + let animation: Animation; + try { + animation = element.animate( + [{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }], + { + duration, + easing: "linear", + iterations: Infinity, + }, + ); + } catch { + element.style.animation = inlineAnimation; + element.style.transform = inlineTransform; + registration.unregister(); + visibility.dispose(); + return; + } + registration.setAnimation(animation); setCompositorReady(true); - return () => animation.cancel(); + return () => { + animation.cancel(); + registration.unregister(); + visibility.dispose(); + }; }, [className]); const compositorStyle = compositorReady diff --git a/apps/packages/ui/src/index.ts b/apps/packages/ui/src/index.ts index 4238af1ef1..cf4453ab2d 100644 --- a/apps/packages/ui/src/index.ts +++ b/apps/packages/ui/src/index.ts @@ -32,6 +32,7 @@ export * from "./menubar"; export * from "./navigation-menu"; export * from "./pagination"; export * from "./pannel-session"; +export * from "./persistent-motion-visibility"; export * from "./popover"; export * from "./progress"; export * from "./radio-group"; diff --git a/apps/packages/ui/src/persistent-motion-visibility.tsx b/apps/packages/ui/src/persistent-motion-visibility.tsx new file mode 100644 index 0000000000..6cc927d274 --- /dev/null +++ b/apps/packages/ui/src/persistent-motion-visibility.tsx @@ -0,0 +1,202 @@ +type PersistentMotionVisibilityOptions = { + ownerVisible?: boolean; +}; + +type PersistentMotionRegistration = { + setAnimation: (animation: Animation | null) => void; + unregister: () => void; +}; + +type PersistentMotionVisibilityController = { + register: (element: HTMLElement, animation?: Animation | null) => PersistentMotionRegistration; + setOwnerVisible: (visible: boolean) => void; + isVisible: () => boolean; + dispose: () => void; +}; + +type Registration = { + element: HTMLElement; + animation: Animation | null; + previousPlayState: string; + cssPaused: boolean; + resumeAnimation: boolean; + active: boolean; +}; + +type DocumentVisibilitySubscription = { + subscribers: Set<() => void>; + listener: () => void; +}; + +const documentVisibilitySubscriptions = new WeakMap(); + +function subscribeToDocumentVisibility(doc: Document, listener: () => void): () => void { + let subscription = documentVisibilitySubscriptions.get(doc); + if (!subscription) { + const subscribers = new Set<() => void>(); + const onVisibilityChange = () => { + for (const subscriber of subscribers) subscriber(); + }; + subscription = { subscribers, listener: onVisibilityChange }; + documentVisibilitySubscriptions.set(doc, subscription); + doc.addEventListener("visibilitychange", onVisibilityChange); + } + + subscription.subscribers.add(listener); + let subscribed = true; + return () => { + if (!subscribed) return; + subscribed = false; + subscription?.subscribers.delete(listener); + if (subscription?.subscribers.size !== 0) return; + doc.removeEventListener("visibilitychange", subscription.listener); + documentVisibilitySubscriptions.delete(doc); + }; +} + +function playOwnedAnimation(animation: Animation): void { + try { + animation.play(); + } catch { + // A handle can be canceled by its owner between the visibility event and + // this callback. Cleanup remains the owner's responsibility. + } +} + +function pauseOwnedAnimation(animation: Animation): void { + try { + animation.pause(); + } catch { + // A canceled handle no longer needs lifecycle control. + } +} + +export function createPersistentMotionVisibility( + target: Element, + options: PersistentMotionVisibilityOptions = {}, +): PersistentMotionVisibilityController { + const doc = target.ownerDocument; + let ownerVisible = options.ownerVisible ?? true; + let intersectionVisible = true; + let currentVisible = doc.visibilityState !== "hidden" && ownerVisible; + let disposed = false; + const registrations = new Set(); + const registeredElements = new Set(); + const notify = () => { + if (disposed) return; + const nextVisible = doc.visibilityState !== "hidden" && ownerVisible && intersectionVisible; + if (nextVisible === currentVisible) return; + currentVisible = nextVisible; + for (const registration of registrations) applyVisibility(registration, currentVisible); + }; + + const unsubscribeDocument = subscribeToDocumentVisibility(doc, notify); + let observer: IntersectionObserver | null = null; + if (typeof IntersectionObserver !== "undefined") { + observer = new IntersectionObserver((entries) => { + const entry = entries[entries.length - 1]; + if (!entry) return; + // The observer uses the default threshold of zero. At the viewport edge + // an intersecting target can have a zero intersection ratio, and it is + // still visible for threshold-zero motion control. + intersectionVisible = entry.isIntersecting; + notify(); + }); + observer.observe(target); + } + + const unregister = (registration: Registration) => { + if (!registration.active) return; + registration.active = false; + registrations.delete(registration); + registeredElements.delete(registration.element); + if (registration.cssPaused) { + registration.element.style.animationPlayState = registration.previousPlayState; + registration.cssPaused = false; + } + registration.animation = null; + registration.resumeAnimation = false; + }; + + return { + register(element, animation = null) { + if (registeredElements.has(element)) { + throw new Error("An element can only be registered once per visibility controller"); + } + registeredElements.add(element); + const registration: Registration = { + element, + animation, + previousPlayState: element.style.animationPlayState, + cssPaused: false, + resumeAnimation: false, + active: true, + }; + registrations.add(registration); + applyVisibility(registration, currentVisible); + + let active = true; + return { + setAnimation(nextAnimation) { + if (!active) return; + registration.animation = nextAnimation; + registration.resumeAnimation = false; + applyVisibility(registration, currentVisible); + }, + unregister() { + if (!active) return; + active = false; + unregister(registration); + }, + }; + }, + setOwnerVisible(visible) { + if (ownerVisible === visible) return; + ownerVisible = visible; + notify(); + }, + isVisible() { + return currentVisible; + }, + dispose() { + if (disposed) return; + disposed = true; + observer?.disconnect(); + observer = null; + unsubscribeDocument(); + for (const registration of [...registrations]) unregister(registration); + }, + }; +} + +function applyVisibility(registration: Registration, visible: boolean): void { + if (!registration.active) return; + if (!visible) { + // Only resume an effect that this controller paused; caller-paused and + // reduced-motion effects remain paused when the target becomes visible. + if (registration.animation && registration.animation.playState === "running") { + registration.resumeAnimation = true; + pauseOwnedAnimation(registration.animation); + } + if (!registration.cssPaused) { + registration.element.style.animationPlayState = "paused"; + registration.cssPaused = true; + } + return; + } + + if (registration.cssPaused) { + registration.element.style.animationPlayState = registration.previousPlayState; + registration.cssPaused = false; + } + if (registration.resumeAnimation && registration.animation) { + playOwnedAnimation(registration.animation); + } + registration.resumeAnimation = false; +} + +export type { + PersistentMotionRegistration, + PersistentMotionVisibilityController, + PersistentMotionVisibilityOptions, +}; diff --git a/apps/web/components/grid-spinner.test.tsx b/apps/web/components/grid-spinner.test.tsx index 89ca63b901..89e3f36313 100644 --- a/apps/web/components/grid-spinner.test.tsx +++ b/apps/web/components/grid-spinner.test.tsx @@ -4,7 +4,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { GridSpinner } from "./grid-spinner"; const delays = ["0.2s", "0.3s", "0.4s", "0.1s", "0.2s", "0.3s", "0s", "0.1s", "0.2s"]; +const CUBE_SELECTOR = ".spinner-grid-cube"; const originalAnimate = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "animate"); +const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); afterEach(() => { cleanup(); @@ -14,6 +16,11 @@ afterEach(() => { } else { Reflect.deleteProperty(HTMLElement.prototype, "animate"); } + if (originalVisibilityState) { + Object.defineProperty(document, "visibilityState", originalVisibilityState); + } else { + Reflect.deleteProperty(document, "visibilityState"); + } }); function installComputedAnimationStyles(easing = "ease-in-out") { @@ -38,10 +45,51 @@ function installAnimate(implementation?: (...args: unknown[]) => Animation) { } function makeAnimation() { - return { cancel: vi.fn() } as unknown as Animation; + let playState: AnimationPlayState = "running"; + return { + get playState() { + return playState; + }, + cancel: vi.fn(() => { + playState = "idle"; + }), + pause: vi.fn(() => { + playState = "paused"; + }), + play: vi.fn(() => { + playState = "running"; + }), + } as unknown as Animation; } describe("GridSpinner", () => { + it("pauses and resumes all owned cube effects with document visibility", () => { + installComputedAnimationStyles(); + const animations = Array.from({ length: 9 }, () => makeAnimation()); + let animationIndex = 0; + installAnimate(() => animations[animationIndex++]); + + const { container } = render(); + const cubes = Array.from(container.querySelectorAll(CUBE_SELECTOR)); + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + document.dispatchEvent(new Event("visibilitychange")); + + for (const animation of animations) expect(animation.pause).toHaveBeenCalledOnce(); + expect(cubes.every((cube) => cube.style.animationPlayState === "paused")).toBe(true); + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + document.dispatchEvent(new Event("visibilitychange")); + + for (const animation of animations) expect(animation.play).toHaveBeenCalledOnce(); + expect(cubes.every((cube) => cube.style.animationPlayState === "")).toBe(true); + }); + it("starts nine compositor transform effects with the CSS timing and stagger", () => { installComputedAnimationStyles(); const animate = installAnimate(); @@ -51,7 +99,7 @@ describe("GridSpinner", () => { const status = container.querySelector('[role="status"]'); expect(status?.getAttribute("aria-label")).toBe("Loading"); expect(status?.className).toContain("text-primary"); - const cubes = Array.from(container.querySelectorAll(".spinner-grid-cube")); + const cubes = Array.from(container.querySelectorAll(CUBE_SELECTOR)); expect(cubes).toHaveLength(9); expect(animate).toHaveBeenCalledTimes(9); expect(animate.mock.calls.map((call) => call[0])).toEqual( @@ -78,11 +126,27 @@ describe("GridSpinner", () => { const { container } = render(); - const cubes = Array.from(container.querySelectorAll(".spinner-grid-cube")); + const cubes = Array.from(container.querySelectorAll(CUBE_SELECTOR)); expect(cubes).toHaveLength(9); expect(cubes.every((cube) => cube.style.animation === "")).toBe(true); - }); + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + document.dispatchEvent(new Event("visibilitychange")); + expect(cubes.every((cube) => cube.style.animationPlayState === "paused")).toBe(true); + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + document.dispatchEvent(new Event("visibilitychange")); + expect(cubes.every((cube) => cube.style.animationPlayState === "")).toBe(true); + }); +}); + +describe("GridSpinner lifecycle", () => { it("preserves commas inside CSS easing functions", () => { installComputedAnimationStyles("cubic-bezier(0.4, 0, 0.6, 1)"); const animate = installAnimate(); @@ -120,10 +184,24 @@ describe("GridSpinner", () => { const { container } = render(); - const cubes = Array.from(container.querySelectorAll(".spinner-grid-cube")); + const cubes = Array.from(container.querySelectorAll(CUBE_SELECTOR)); expect(animate).toHaveBeenCalledTimes(2); expect(firstAnimation.cancel).toHaveBeenCalledOnce(); expect(cubes.every((cube) => cube.style.animation === "")).toBe(true); + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + document.dispatchEvent(new Event("visibilitychange")); + expect(cubes.every((cube) => cube.style.animationPlayState === "paused")).toBe(true); + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + document.dispatchEvent(new Event("visibilitychange")); + expect(cubes.every((cube) => cube.style.animationPlayState === "")).toBe(true); }); it("cancels every compositor effect when it unmounts", () => { diff --git a/apps/web/components/grid-spinner.tsx b/apps/web/components/grid-spinner.tsx index 7a0731b6b3..19ad74ca2b 100644 --- a/apps/web/components/grid-spinner.tsx +++ b/apps/web/components/grid-spinner.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { firstAnimationListValue, parseCssTime } from "@kandev/ui/animation-utils"; +import { createPersistentMotionVisibility } from "@kandev/ui/persistent-motion-visibility"; type GridSpinnerProps = { className?: string; @@ -24,13 +25,27 @@ export function GridSpinner({ className }: GridSpinnerProps) { const cubes = Array.from( gridRef.current?.querySelectorAll(".spinner-grid-cube") ?? [], ); + const grid = gridRef.current; + if (!grid) return; + const visibility = createPersistentMotionVisibility(grid); + const registrations = cubes.map((cube) => visibility.register(cube)); const animations = startGridAnimations(cubes); - if (!animations) return; + if (!animations) { + return () => { + for (const registration of registrations) registration.unregister(); + visibility.dispose(); + }; + } - for (const cube of cubes) cube.style.animation = "none"; + for (let index = 0; index < cubes.length; index += 1) { + registrations[index]?.setAnimation(animations[index] ?? null); + cubes[index].style.animation = "none"; + } return () => { for (const animation of animations) animation.cancel(); + for (const registration of registrations) registration.unregister(); + visibility.dispose(); for (const cube of cubes) cube.style.removeProperty("animation"); }; }, []); diff --git a/apps/web/components/task/chat/token-usage-display.test.tsx b/apps/web/components/task/chat/token-usage-display.test.tsx index f7a80c8a4e..1542cdb87a 100644 --- a/apps/web/components/task/chat/token-usage-display.test.tsx +++ b/apps/web/components/task/chat/token-usage-display.test.tsx @@ -69,6 +69,24 @@ describe("isContextWindowReliable", () => { }); describe("TokenUsageDisplay", () => { + it("transitions only the context ring arc", () => { + vi.mocked(useSessionContextWindow).mockReturnValue({ + size: 200_000, + used: 56_047, + remaining: 143_953, + efficiency: 28, + compactionCount: 0, + }); + + const { container } = render(); + const circles = container.querySelectorAll("circle"); + const usageCircle = circles[1]; + + expect(usageCircle).toBeDefined(); + expect(usageCircle.getAttribute("class")).toContain("transition-[stroke-dashoffset]"); + expect(usageCircle.getAttribute("class")).not.toContain("transition-all"); + }); + it("renders nothing when used exceeds size (wrong-window bug)", () => { vi.mocked(useSessionContextWindow).mockReturnValue({ size: 200_000, diff --git a/apps/web/components/task/chat/token-usage-display.tsx b/apps/web/components/task/chat/token-usage-display.tsx index 0597834b29..95cbc9df1f 100644 --- a/apps/web/components/task/chat/token-usage-display.tsx +++ b/apps/web/components/task/chat/token-usage-display.tsx @@ -129,7 +129,10 @@ function ContextWindowRing({ usagePercent }: { usagePercent: number }) { strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={strokeDashoffset} - className={cn(getCircleColor(usagePercent), "transition-all duration-300 ease-out")} + className={cn( + getCircleColor(usagePercent), + "transition-[stroke-dashoffset] duration-300 ease-out", + )} /> ); diff --git a/apps/web/e2e/helpers/animation-assertions.ts b/apps/web/e2e/helpers/animation-assertions.ts index bb797de113..fb41e9ebc6 100644 --- a/apps/web/e2e/helpers/animation-assertions.ts +++ b/apps/web/e2e/helpers/animation-assertions.ts @@ -36,3 +36,19 @@ export async function expectCompositorPulse(pulse: Locator) { ) .toBe(true); } + +export async function expectCompositorMotionPaused(target: Locator) { + await expect + .poll( + () => + target.evaluate((element) => { + const animations = element.getAnimations(); + return ( + (element as HTMLElement).style.animationPlayState === "paused" && + animations.every((animation) => animation.playState !== "running") + ); + }), + { timeout: 5_000 }, + ) + .toBe(true); +} diff --git a/apps/web/e2e/scripts/kubernetes-pins.test.ts b/apps/web/e2e/scripts/kubernetes-pins.test.ts index eb8e5ba64c..57193cfc66 100644 --- a/apps/web/e2e/scripts/kubernetes-pins.test.ts +++ b/apps/web/e2e/scripts/kubernetes-pins.test.ts @@ -6,8 +6,8 @@ import { KIND_SHA256_AMD64, KIND_VERSION, KUBECTL_SHA256_AMD64, - KUBERNETES_FIXTURE_PINS, KUBERNETES_E2E_BASE_IMAGE, + KUBERNETES_FIXTURE_PINS, KUBERNETES_VERSION, resolveKubernetesFixturePin, } from "../fixtures/kubernetes-pins"; diff --git a/apps/web/e2e/tests/chat/animation-performance-trace.spec.ts b/apps/web/e2e/tests/chat/animation-performance-trace.spec.ts index e84a59c181..32f831a474 100644 --- a/apps/web/e2e/tests/chat/animation-performance-trace.spec.ts +++ b/apps/web/e2e/tests/chat/animation-performance-trace.spec.ts @@ -7,32 +7,77 @@ import { openQuickChatWithAgent, sendQuickChatMessage } from "./quick-chat-helpe const TRACE_WINDOW_MS = 8_340; const TARGET_PATTERN = /spinner-grid-cube|chat-input-glow-(?:running|starting)/; +const TARGET_SELECTORS = { + grid: ".spinner-grid-cube", + pulse: "[data-compositor-pulse]", +} as const; +const TRACE_REPEATS = parsePositiveInteger(process.env.KANDEV_E2E_ANIMATION_TRACE_REPEATS ?? "1"); type TraceEvent = { name?: string; args?: unknown; + dur?: number; +}; + +type TraceMetric = { + count: number; + durationMs: number; }; type TraceMetrics = { - updateLayoutTree: number; - layerize: number; - layout: number; - paint: number; + updateLayoutTree: TraceMetric; + layerize: TraceMetric; + layout: TraceMetric; + paint: TraceMetric; + runTask: TraceMetric; + functionCall: TraceMetric; targetInvalidations: number; + targetInvalidationsByGroup: Record; +}; + +type NumericSummary = { + min: number; + median: number; + max: number; +}; + +type TraceMetricsSummary = { + updateLayoutTree: { count: NumericSummary; durationMs: NumericSummary }; + layerize: { count: NumericSummary; durationMs: NumericSummary }; + layout: { count: NumericSummary; durationMs: NumericSummary }; + paint: { count: NumericSummary; durationMs: NumericSummary }; + runTask: { count: NumericSummary; durationMs: NumericSummary }; + functionCall: { count: NumericSummary; durationMs: NumericSummary }; + targetInvalidations: NumericSummary; + targetInvalidationsByGroup: Record; +}; + +type AnimationInventory = { + css: Array<{ + target: string; + pseudo: string | null; + animationName: string; + }>; + webAnimations: Array<{ + target: string; + constructor: string; + playState: string; + animationName: string | null; + }>; }; test.skip( process.env.KANDEV_E2E_ANIMATION_TRACE !== "1", "Run explicitly to capture the 8.34-second animation performance control.", ); -test.describe.configure({ retries: 1 }); +test.describe.configure({ retries: process.env.KANDEV_E2E_ANIMATION_TRACE === "1" ? 0 : 1 }); test("attributes steady animation work for compositor and CSS fallback paths", async ({ testPage, apiClient, seedData, }, testInfo) => { - test.setTimeout(120_000); + test.setTimeout(900_000); const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "Animation performance trace", @@ -48,16 +93,22 @@ test("attributes steady animation work for compositor and CSS fallback paths", a const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 30_000 }); - await session.sendMessage("/slow 30s"); + await session.sendMessage("/slow 300s"); const glow = session.activeChat().getByTestId("chat-input-glow"); - await expect(glow).toBeVisible(); + await expect(glow).toBeVisible({ timeout: 30_000 }); const dialog = await openQuickChatWithAgent(testPage); - await sendQuickChatMessage(dialog, testPage, "/slow 30s"); + await sendQuickChatMessage(dialog, testPage, "/slow 300s"); const cubes = dialog.locator(".spinner-grid-cube"); await expect(cubes).toHaveCount(18); + await expectTraceTargetsPresent(testPage); await expectCompositorTargets(testPage); + const normalInventory = await inspectAnimationInventory(testPage); + const normalRuns = await captureRepeatedTraces(testPage, testInfo, "normal-page", { + disableScriptExecution: false, + }); + const disableCssMotion = await testPage.addStyleTag({ content: "*,*::before,*::after{animation:none!important;transition:none!important}", }); @@ -66,39 +117,185 @@ test("attributes steady animation work for compositor and CSS fallback paths", a expect(compositor.targetInvalidations).toBe(0); await disableCssMotion.evaluate((element) => element.remove()); - await installCssFallbackControl(testPage); - await dwell( - 1_000, - "clock-separation", - "exclude fallback style installation from the trace window", - ); - const cssFallback = await captureTrace(testPage, testInfo, "css-fallback-motion"); - console.info(`[animation-performance-trace] ${JSON.stringify({ compositor, cssFallback })}`); - expect(cssFallback.updateLayoutTree).toBeGreaterThan(0); - expect(cssFallback.layerize).toBeGreaterThan(0); + const removeCssFallback = await installCssFallbackControl(testPage); + try { + await dwell( + 1_000, + "clock-separation", + "exclude fallback style installation from the trace window", + ); + const cssFallback = await captureRepeatedTraces(testPage, testInfo, "css-fallback-motion", { + disableScriptExecution: true, + }); + const cssFallbackSummary = summarizeMetrics(cssFallback); + + const isolatedRuns = await captureMotionIsolationArms(testPage, testInfo); + const allMotionPaused = isolatedRuns.find((run) => run.arm === "all-motion-paused"); + expect(allMotionPaused).toBeDefined(); + expect(cssFallbackSummary.targetInvalidations.max).toBeGreaterThan(0); + + const cssFallbackBaseline = isolatedRuns.find((run) => run.arm === "css-fallback-baseline"); + expect(cssFallbackBaseline).toBeDefined(); + if (!cssFallbackBaseline) throw new Error("Missing script-enabled CSS fallback baseline"); + const cssFallbackBaselineSummary = summarizeMetrics(cssFallbackBaseline.metrics); + for (const [arm, group] of [ + ["grid-paused", "grid"], + ["pulse-paused", "pulse"], + ] as const) { + const pausedRun = isolatedRuns.find((run) => run.arm === arm); + expect(pausedRun).toBeDefined(); + if (!pausedRun) throw new Error(`Missing ${arm} isolation run`); + const pausedSummary = summarizeMetrics(pausedRun.metrics); + expect(pausedSummary.targetInvalidationsByGroup[group].median).toBeLessThan( + cssFallbackBaselineSummary.targetInvalidationsByGroup[group].median, + ); + } + const allMotionPausedSummary = summarizeMetrics(allMotionPaused!.metrics); + for (const group of ["grid", "pulse"] as const) { + expect(allMotionPausedSummary.targetInvalidationsByGroup[group].median).toBeLessThan( + cssFallbackBaselineSummary.targetInvalidationsByGroup[group].median, + ); + } + + const finalInventory = await inspectAnimationInventory(testPage); + await testInfo.attach("animation-attribution-report.json", { + body: Buffer.from( + JSON.stringify( + { + repeats: TRACE_REPEATS, + normalInventory, + finalInventory, + normalRuns, + normalSummary: summarizeMetrics(normalRuns), + compositor, + cssFallback, + cssFallbackSummary, + cssFallbackBaselineSummary, + isolatedRuns, + isolatedSummaries: isolatedRuns.map((run) => ({ + arm: run.arm, + summary: summarizeMetrics(run.metrics), + })), + }, + null, + 2, + ), + ), + contentType: "application/json", + }); + console.info( + `[animation-performance-trace] ${JSON.stringify({ + repeats: TRACE_REPEATS, + normalRuns, + normalSummary: summarizeMetrics(normalRuns), + compositor, + cssFallback, + cssFallbackSummary, + cssFallbackBaselineSummary, + isolatedRuns, + isolatedSummaries: isolatedRuns.map((run) => ({ + arm: run.arm, + summary: summarizeMetrics(run.metrics), + })), + normalInventory, + finalInventory, + })}`, + ); + } finally { + await removeCssFallback(); + } }); +type TraceCaptureOptions = { + disableScriptExecution?: boolean; +}; + +type IsolationArm = "css-fallback-baseline" | "grid-paused" | "pulse-paused" | "all-motion-paused"; + +type IsolationRun = { + arm: IsolationArm; + metrics: TraceMetrics[]; +}; + +async function captureRepeatedTraces( + page: Page, + testInfo: TestInfo, + label: string, + options: TraceCaptureOptions, +): Promise { + const metrics: TraceMetrics[] = []; + for (let repeat = 1; repeat <= TRACE_REPEATS; repeat += 1) { + await expectTraceTargetsPresent(page); + metrics.push(await captureTrace(page, testInfo, `${label}-${repeat}`, options)); + } + return metrics; +} + +async function expectTraceTargetsPresent(page: Page) { + await expect(page.locator(TARGET_SELECTORS.grid)).toHaveCount(18); + await expect(page.locator(TARGET_SELECTORS.pulse)).toHaveCount(1); +} + +async function captureMotionIsolationArms(page: Page, testInfo: TestInfo): Promise { + const arms: Array<{ arm: IsolationArm; selectors: string[] }> = [ + { arm: "css-fallback-baseline", selectors: [] }, + { arm: "grid-paused", selectors: [TARGET_SELECTORS.grid] }, + { arm: "pulse-paused", selectors: [TARGET_SELECTORS.pulse] }, + { arm: "all-motion-paused", selectors: [] }, + ]; + const runs: IsolationRun[] = []; + for (const { arm, selectors } of arms) { + await expectTraceTargetsPresent(page); + const restore = + arm === "css-fallback-baseline" + ? async () => undefined + : await pauseMotion(page, selectors, arm === "all-motion-paused"); + try { + if (arm === "css-fallback-baseline") { + await expectCssFallbackTargets(page); + } else { + await expectMotionPaused(page, selectors, arm === "all-motion-paused"); + if (arm === "grid-paused") { + await expectCssFallbackGroupRunning(page, TARGET_SELECTORS.pulse); + } else if (arm === "pulse-paused") { + await expectCssFallbackGroupRunning(page, TARGET_SELECTORS.grid); + } + } + const metrics = await captureRepeatedTraces(page, testInfo, arm, { + disableScriptExecution: false, + }); + runs.push({ arm, metrics }); + } finally { + await restore(); + await expectCssFallbackTargets(page); + } + } + return runs; +} + async function expectCompositorTargets(page: Page) { await expect .poll(() => page .locator("[data-compositor-pulse], .spinner-grid-cube") - .evaluateAll((elements) => - elements.every((element) => - element - .getAnimations() - .some( - (animation) => - animation.constructor.name === "Animation" && animation.playState === "running", - ), - ), + .evaluateAll( + (elements) => + elements.length > 0 && + elements.every((element) => + element + .getAnimations() + .some( + (animation) => + animation.constructor.name === "Animation" && animation.playState === "running", + ), + ), ), ) .toBe(true); } -async function installCssFallbackControl(page: Page) { - await page.addStyleTag({ +async function installCssFallbackControl(page: Page): Promise<() => Promise> { + const style = await page.addStyleTag({ content: ` *,*::before,*::after{animation:none!important;transition:none!important} .spinner-grid-cube{animation:spinner-grid 1.3s ease-in-out infinite!important} @@ -114,31 +311,188 @@ async function installCssFallbackControl(page: Page) { (element as HTMLElement).style.removeProperty("animation"); } }); + await expectCssFallbackTargets(page); + return async () => { + await style.evaluate((element) => element.remove()).catch(() => undefined); + }; +} + +async function expectCssFallbackTargets(page: Page) { await expect .poll(() => page .locator("[data-compositor-pulse], .spinner-grid-cube") - .evaluateAll((elements) => - elements.every((element) => - element - .getAnimations() - .some( - (animation) => - animation.constructor.name === "CSSAnimation" && - animation.playState === "running", - ), - ), + .evaluateAll( + (elements) => + elements.length > 0 && + elements.every((element) => + element + .getAnimations() + .some( + (animation) => + animation.constructor.name === "CSSAnimation" && + animation.playState === "running", + ), + ), ), ) .toBe(true); } -async function captureTrace(page: Page, testInfo: TestInfo, label: string): Promise { +async function expectCssFallbackGroupRunning(page: Page, selector: string) { + await expect + .poll(() => + page + .locator(selector) + .evaluateAll( + (elements) => + elements.length > 0 && + elements.every((element) => + element + .getAnimations() + .some( + (animation) => + animation.constructor.name === "CSSAnimation" && + animation.playState === "running", + ), + ), + ), + ) + .toBe(true); +} + +async function expectMotionPaused(page: Page, selectors: string[], all: boolean) { + await expect + .poll(() => + page.evaluate( + ({ selectors: targetSelectors, pauseAll }) => { + const animations = document.getAnimations().filter((animation) => { + if (pauseAll) return true; + const target = animation.effect?.target; + return ( + target instanceof Element && + targetSelectors.some((selector) => target.matches(selector)) + ); + }); + return ( + animations.length > 0 && + animations.every((animation) => animation.playState !== "running") + ); + }, + { selectors, pauseAll: all }, + ), + ) + .toBe(true); +} + +async function pauseMotion( + page: Page, + selectors: string[], + all: boolean, +): Promise<() => Promise> { + await page.evaluate( + ({ selectors: targetSelectors, pauseAll }) => { + const state = { + animations: document + .getAnimations() + .filter((animation) => { + if (pauseAll) return true; + const target = animation.effect?.target; + return ( + target instanceof Element && + targetSelectors.some((selector) => target.matches(selector)) + ); + }) + .map((animation) => ({ animation, playState: animation.playState })), + style: document.createElement("style"), + }; + const cssSelector = pauseAll + ? "*,*::before,*::after" + : targetSelectors + .flatMap((selector) => [selector, `${selector}::before`, `${selector}::after`]) + .join(","); + state.style.textContent = `${cssSelector}{animation-play-state:paused!important}`; + document.head.appendChild(state.style); + for (const entry of state.animations) entry.animation.pause(); + const pageWindow = window as Window & { + __kandevMotionPauseState?: typeof state; + }; + pageWindow.__kandevMotionPauseState = state; + }, + { selectors, pauseAll: all }, + ); + return async () => { + await page.evaluate(() => { + const pageWindow = window as Window & { + __kandevMotionPauseState?: { + animations: Array<{ animation: Animation; playState: AnimationPlayState }>; + style: HTMLStyleElement; + }; + }; + const state = pageWindow.__kandevMotionPauseState; + if (!state) return; + state.style.remove(); + for (const { animation, playState } of state.animations) { + if (playState === "running") animation.play(); + else if (playState === "paused") animation.pause(); + else if (playState === "finished") animation.finish(); + else animation.cancel(); + } + delete pageWindow.__kandevMotionPauseState; + }); + }; +} + +async function inspectAnimationInventory(page: Page): Promise { + return page.evaluate(() => { + const describe = (element: Element): string => { + const id = element.id ? `#${element.id}` : ""; + const testId = element.getAttribute("data-testid"); + const testIdLabel = testId ? `[data-testid=${testId}]` : ""; + const classes = + typeof element.className === "string" + ? `.${element.className.trim().replace(/\s+/g, ".")}` + : ""; + return `${element.tagName.toLowerCase()}${id}${testIdLabel}${classes}`; + }; + const css: AnimationInventory["css"] = []; + for (const element of Array.from(document.querySelectorAll("*"))) { + for (const pseudo of [null, "::before", "::after"] as const) { + const computed = window.getComputedStyle(element, pseudo); + if (computed.animationName && computed.animationName !== "none") { + css.push({ target: describe(element), pseudo, animationName: computed.animationName }); + } + } + } + const webAnimations: AnimationInventory["webAnimations"] = []; + for (const animation of document.getAnimations()) { + const target = animation.effect?.target; + if (!(target instanceof Element)) continue; + webAnimations.push({ + target: describe(target), + constructor: animation.constructor.name, + playState: animation.playState, + animationName: + target instanceof HTMLElement ? window.getComputedStyle(target).animationName : null, + }); + } + return { css, webAnimations }; + }); +} + +async function captureTrace( + page: Page, + testInfo: TestInfo, + label: string, + options: TraceCaptureOptions = {}, +): Promise { const client = await page.context().newCDPSession(page); let trace = ""; let tracingStarted = false; try { - await client.send("Emulation.setScriptExecutionDisabled", { value: true }); + await client.send("Emulation.setScriptExecutionDisabled", { + value: options.disableScriptExecution ?? true, + }); await dwell( 1_000, "clock-separation", @@ -204,18 +558,75 @@ async function readTraceStream(client: CDPSession, stream: string): Promise event.name?.includes("InvalidationTracking") && TARGET_PATTERN.test(JSON.stringify(event.args)), ).length, + targetInvalidationsByGroup: { + grid: countInvalidations(events, /spinner-grid-cube/), + pulse: countInvalidations(events, /chat-input-glow-(?:running|starting)/), + }, }; } -function countNamed(events: TraceEvent[], name: string): number { - return events.filter((event) => event.name === name).length; +function metricNamed(events: TraceEvent[], name: string): TraceMetric { + const matchingEvents = events.filter((event) => event.name === name); + return { + count: matchingEvents.length, + durationMs: matchingEvents.reduce((total, event) => total + (event.dur ?? 0), 0) / 1_000, + }; +} + +function summarizeMetrics(metrics: TraceMetrics[]): TraceMetricsSummary { + if (metrics.length === 0) throw new Error("Cannot summarize an empty trace set"); + const summarize = (read: (metric: TraceMetrics) => number): NumericSummary => + summarizeNumbers(metrics.map(read)); + const summarizeTraceMetric = ( + read: (metric: TraceMetrics) => TraceMetric, + ): { count: NumericSummary; durationMs: NumericSummary } => ({ + count: summarizeNumbers(metrics.map((metric) => read(metric).count)), + durationMs: summarizeNumbers(metrics.map((metric) => read(metric).durationMs)), + }); + + return { + updateLayoutTree: summarizeTraceMetric((metric) => metric.updateLayoutTree), + layerize: summarizeTraceMetric((metric) => metric.layerize), + layout: summarizeTraceMetric((metric) => metric.layout), + paint: summarizeTraceMetric((metric) => metric.paint), + runTask: summarizeTraceMetric((metric) => metric.runTask), + functionCall: summarizeTraceMetric((metric) => metric.functionCall), + targetInvalidations: summarize((metric) => metric.targetInvalidations), + targetInvalidationsByGroup: { + grid: summarize((metric) => metric.targetInvalidationsByGroup.grid), + pulse: summarize((metric) => metric.targetInvalidationsByGroup.pulse), + }, + }; +} + +function summarizeNumbers(values: number[]): NumericSummary { + if (values.length === 0) throw new Error("Cannot summarize an empty value set"); + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]; + return { min: sorted[0], median, max: sorted[sorted.length - 1] }; +} + +function countInvalidations(events: TraceEvent[], pattern: RegExp): number { + return events.filter( + (event) => + event.name?.includes("InvalidationTracking") && pattern.test(JSON.stringify(event.args)), + ).length; +} + +function parsePositiveInteger(value: string): number { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 1; } diff --git a/apps/web/e2e/tests/chat/context-window-source-helpers.ts b/apps/web/e2e/tests/chat/context-window-source-helpers.ts index 4a14c4744e..0dddeffca5 100644 --- a/apps/web/e2e/tests/chat/context-window-source-helpers.ts +++ b/apps/web/e2e/tests/chat/context-window-source-helpers.ts @@ -14,7 +14,7 @@ type ContextWindowStore = Window & { }; }; -type ContextWindowFixture = { +export type ContextWindowFixture = { size: number; used: number; remaining: number; @@ -30,7 +30,7 @@ export async function seedContextWindowTask( apiClient: ApiClient, seedData: SeedData, seed: ContextWindowSeed = {}, -): Promise { +): Promise<{ sessionId: string }> { const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "Context Window Source Test", @@ -122,6 +122,22 @@ export async function seedContextWindowTask( 0, )}% used`; await expect(testPage.getByRole("button", { name: triggerName })).toBeVisible(); + return { sessionId: task.session_id }; +} + +export async function setContextWindowFixture( + testPage: Page, + sessionId: string, + fixture: ContextWindowFixture, +): Promise { + await testPage.evaluate( + ({ sessionId: candidateId, fixture: candidateFixture }) => { + const store = (window as ContextWindowStore).__KANDEV_E2E_STORE__; + if (!store) throw new Error("E2E store bridge is unavailable"); + store.getState().setContextWindow(candidateId, candidateFixture); + }, + { sessionId, fixture }, + ); } export async function expectCompactionCount(contextTooltip: Locator): Promise { diff --git a/apps/web/e2e/tests/chat/context-window-source.spec.ts b/apps/web/e2e/tests/chat/context-window-source.spec.ts index ee40bdf7dd..9e8c7d8258 100644 --- a/apps/web/e2e/tests/chat/context-window-source.spec.ts +++ b/apps/web/e2e/tests/chat/context-window-source.spec.ts @@ -3,6 +3,7 @@ import { expectCompactionCount, expectSourceRightOfTokenCount, seedContextWindowTask, + setContextWindowFixture, } from "./context-window-source-helpers"; test("context window announces unmeasured usage before the first sample", async ({ @@ -81,3 +82,30 @@ test("context source help stays open when hovered", async ({ caption: "Context source and inferred compaction count shown with their help visible", }); }); + +test("context ring transitions only its arc when usage crosses a color threshold", async ({ + testPage, + apiClient, + seedData, +}) => { + const { sessionId } = await seedContextWindowTask(testPage, apiClient, seedData); + const usageCircle = testPage.locator('button[aria-label^="Context window:"] circle').nth(1); + const initialOffset = await usageCircle.getAttribute("stroke-dashoffset"); + + await expect(usageCircle).toHaveCSS("transition-property", "stroke-dashoffset"); + await expect(usageCircle).toHaveClass(/text-blue-300/); + + await setContextWindowFixture(testPage, sessionId, { + size: 258_400, + used: 200_000, + remaining: 58_400, + efficiency: 77, + compactionCount: 2, + source: "acp", + }); + + await expect(testPage.getByRole("button", { name: "Context window: 77% used" })).toBeVisible(); + await expect.poll(() => usageCircle.getAttribute("stroke-dashoffset")).not.toBe(initialOffset); + await expect(usageCircle).toHaveCSS("transition-property", "stroke-dashoffset"); + await expect(usageCircle).toHaveClass(/text-yellow-300/); +}); diff --git a/apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts b/apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts index 167ff4d733..e6d51e244c 100644 --- a/apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts @@ -3,6 +3,7 @@ import { expectCompactionCount, expectSourceRightOfTokenCount, seedContextWindowTask, + setContextWindowFixture, } from "./context-window-source-helpers"; test("pending context usage is reachable by touch without overflow", async ({ @@ -86,3 +87,35 @@ test("context source help is reachable by touch without overflow", async ({ }); expect(hasHorizontalOverflow).toBe(false); }); + +test("context ring keeps touch layout while its arc crosses a color threshold", async ({ + testPage, + apiClient, + seedData, +}) => { + const { sessionId } = await seedContextWindowTask(testPage, apiClient, seedData); + const usageCircle = testPage.locator('button[aria-label^="Context window:"] circle').nth(1); + const initialOffset = await usageCircle.getAttribute("stroke-dashoffset"); + + await expect(usageCircle).toHaveCSS("transition-property", "stroke-dashoffset"); + await expect(usageCircle).toHaveClass(/text-blue-300/); + + await setContextWindowFixture(testPage, sessionId, { + size: 258_400, + used: 200_000, + remaining: 58_400, + efficiency: 77, + compactionCount: 2, + source: "acp", + }); + + await expect(testPage.getByRole("button", { name: "Context window: 77% used" })).toBeVisible(); + await expect.poll(() => usageCircle.getAttribute("stroke-dashoffset")).not.toBe(initialOffset); + await expect(usageCircle).toHaveCSS("transition-property", "stroke-dashoffset"); + await expect(usageCircle).toHaveClass(/text-yellow-300/); + const hasHorizontalOverflow = await testPage.evaluate(() => { + const root = document.scrollingElement ?? document.documentElement; + return root.scrollWidth > root.clientWidth + 1; + }); + expect(hasHorizontalOverflow).toBe(false); +}); diff --git a/apps/web/e2e/tests/chat/mobile-persistent-animation-motion.spec.ts b/apps/web/e2e/tests/chat/mobile-persistent-animation-motion.spec.ts index 01f24d7434..463146b639 100644 --- a/apps/web/e2e/tests/chat/mobile-persistent-animation-motion.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-persistent-animation-motion.spec.ts @@ -1,5 +1,8 @@ import { test, expect } from "../../fixtures/test-base"; -import { expectCompositorPulse } from "../../helpers/animation-assertions"; +import { + expectCompositorMotionPaused, + expectCompositorPulse, +} from "../../helpers/animation-assertions"; import { SessionPage } from "../../pages/session-page"; test("keeps the mobile composer pulse contained until the turn settles", async ({ @@ -27,6 +30,31 @@ test("keeps the mobile composer pulse contained until the turn settles", async ( await session.sendMessageViaButton("/slow 8s"); const glow = session.activeChat().getByTestId("chat-input-glow"); await expectCompositorPulse(glow); + + await glow.evaluate((element) => { + (element as HTMLElement).style.display = "none"; + }); + await expectCompositorMotionPaused(glow); + await glow.evaluate((element) => { + (element as HTMLElement).style.removeProperty("display"); + }); + await expectCompositorPulse(glow); + + await glow.evaluate((element) => { + (element as HTMLElement).style.transform = "translateY(200vh)"; + }); + await expectCompositorMotionPaused(glow); + await glow.evaluate((element) => { + element.scrollIntoView({ block: "center", inline: "nearest" }); + }); + await expectCompositorPulse(glow); + await glow.evaluate((element) => { + (element as HTMLElement).style.removeProperty("transform"); + }); + + await glow.evaluate((element) => { + (element as HTMLElement).style.display = "none"; + }); expect( await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), ).toBe(true); diff --git a/apps/web/e2e/tests/chat/persistent-animation-motion.spec.ts b/apps/web/e2e/tests/chat/persistent-animation-motion.spec.ts index d3ad17f17d..1614f8e676 100644 --- a/apps/web/e2e/tests/chat/persistent-animation-motion.spec.ts +++ b/apps/web/e2e/tests/chat/persistent-animation-motion.spec.ts @@ -1,5 +1,8 @@ import { test, expect } from "../../fixtures/test-base"; -import { expectCompositorPulse } from "../../helpers/animation-assertions"; +import { + expectCompositorMotionPaused, + expectCompositorPulse, +} from "../../helpers/animation-assertions"; import { SessionPage } from "../../pages/session-page"; test("keeps the busy task composer glow animated until the turn settles", async ({ @@ -28,6 +31,31 @@ test("keeps the busy task composer glow animated until the turn settles", async const glow = session.activeChat().getByTestId("chat-input-glow"); await expectCompositorPulse(glow); + await glow.evaluate((element) => { + (element as HTMLElement).style.display = "none"; + }); + await expectCompositorMotionPaused(glow); + await glow.evaluate((element) => { + (element as HTMLElement).style.removeProperty("display"); + }); + await expectCompositorPulse(glow); + + await glow.evaluate((element) => { + (element as HTMLElement).style.transform = "translateY(200vh)"; + }); + await expectCompositorMotionPaused(glow); + await glow.evaluate((element) => { + element.scrollIntoView({ block: "center", inline: "nearest" }); + }); + await expectCompositorPulse(glow); + await glow.evaluate((element) => { + (element as HTMLElement).style.removeProperty("transform"); + }); + + await glow.evaluate((element) => { + (element as HTMLElement).style.display = "none"; + }); + await session.waitForChatIdle({ timeout: 30_000 }); await expect(glow).toHaveCount(0); }); diff --git a/apps/web/lib/ui/compositor-pulse.test.tsx b/apps/web/lib/ui/compositor-pulse.test.tsx index 93b73b8f41..ec772e14e6 100644 --- a/apps/web/lib/ui/compositor-pulse.test.tsx +++ b/apps/web/lib/ui/compositor-pulse.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const originalAnimate = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "animate"); const originalMatchMedia = Object.getOwnPropertyDescriptor(window, "matchMedia"); +const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); afterEach(() => { cleanup(); @@ -18,6 +19,11 @@ afterEach(() => { } else { Reflect.deleteProperty(window, "matchMedia"); } + if (originalVisibilityState) { + Object.defineProperty(document, "visibilityState", originalVisibilityState); + } else { + Reflect.deleteProperty(document, "visibilityState"); + } }); function installComputedAnimationStyles(overrides: Partial = {}) { @@ -31,7 +37,21 @@ function installComputedAnimationStyles(overrides: Partial } function installAnimate() { - const animation = { cancel: vi.fn() } as unknown as Animation; + let playState: AnimationPlayState = "running"; + const animation = { + get playState() { + return playState; + }, + cancel: vi.fn(() => { + playState = "idle"; + }), + pause: vi.fn(() => { + playState = "paused"; + }), + play: vi.fn(() => { + playState = "running"; + }), + } as unknown as Animation; const animate = vi.fn(() => animation); Object.defineProperty(HTMLElement.prototype, "animate", { configurable: true, @@ -63,6 +83,36 @@ function installReducedMotionMediaQuery(initialMatches = false) { }; } +describe("CompositorPulse visibility", () => { + it("pauses and resumes its owned effect with document visibility", () => { + installComputedAnimationStyles(); + const { animate, animation } = installAnimate(); + const { getByTestId } = render( + , + ); + const pulse = getByTestId("pulse"); + + expect(animate).toHaveBeenCalledOnce(); + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + document.dispatchEvent(new Event("visibilitychange")); + + expect(animation.pause).toHaveBeenCalledOnce(); + expect(pulse.style.animationPlayState).toBe("paused"); + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + document.dispatchEvent(new Event("visibilitychange")); + + expect(animation.play).toHaveBeenCalledOnce(); + expect(pulse.style.animationPlayState).toBe(""); + }); +}); + describe("CompositorPulse", () => { it("replaces a CSS pulse with an infinite compositor opacity effect", () => { installComputedAnimationStyles(); @@ -107,8 +157,22 @@ describe("CompositorPulse", () => { const { getByTestId } = render( , ); + const pulse = getByTestId("pulse"); - expect(getByTestId("pulse").style.animation).toBe(""); + expect(pulse.style.animation).toBe(""); + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + document.dispatchEvent(new Event("visibilitychange")); + expect(pulse.style.animationPlayState).toBe("paused"); + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + document.dispatchEvent(new Event("visibilitychange")); + expect(pulse.style.animationPlayState).toBe(""); }); it("does not override CSS reduced-motion suppression", () => { diff --git a/apps/web/lib/ui/compositor-spin.test.tsx b/apps/web/lib/ui/compositor-spin.test.tsx new file mode 100644 index 0000000000..7ede4d0067 --- /dev/null +++ b/apps/web/lib/ui/compositor-spin.test.tsx @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { render } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { getTaskStateIcon } from "./state-icons"; + +const SPIN_SELECTOR = ".animate-spin"; + +describe("CompositorSpin visibility", () => { + it("pauses and resumes the compositor spinner with document visibility", () => { + const originalAnimate = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "animate"); + const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); + let playState: AnimationPlayState = "running"; + const animation = { + get playState() { + return playState; + }, + cancel: vi.fn(() => { + playState = "idle"; + }), + pause: vi.fn(() => { + playState = "paused"; + }), + play: vi.fn(() => { + playState = "running"; + }), + } as unknown as Animation; + Object.defineProperty(HTMLElement.prototype, "animate", { + configurable: true, + value: vi.fn(() => animation), + }); + + try { + const { container } = render( + {getTaskStateIcon("IN_PROGRESS")}, + ); + const wrapper = container.querySelector(SPIN_SELECTOR) as HTMLElement; + + setDocumentVisibility("hidden"); + expect(animation.pause).toHaveBeenCalledOnce(); + expect(wrapper.style.animationPlayState).toBe("paused"); + + setDocumentVisibility("visible"); + expect(animation.play).toHaveBeenCalledOnce(); + expect(wrapper.style.animationPlayState).toBe(""); + } finally { + restoreProperty(HTMLElement.prototype, "animate", originalAnimate); + restoreProperty(document, "visibilityState", originalVisibilityState); + } + }); + + it("pauses the CSS fallback when Web Animations are unavailable", () => { + const originalAnimate = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "animate"); + Reflect.deleteProperty(HTMLElement.prototype, "animate"); + + try { + const { container } = render( + {getTaskStateIcon("IN_PROGRESS")}, + ); + const wrapper = container.querySelector(SPIN_SELECTOR) as HTMLElement; + + setDocumentVisibility("hidden"); + expect(wrapper.style.animationPlayState).toBe("paused"); + + setDocumentVisibility("visible"); + expect(wrapper.style.animationPlayState).toBe(""); + } finally { + restoreProperty(HTMLElement.prototype, "animate", originalAnimate); + } + }); + + it("cleans up visibility control when compositor setup throws", () => { + const originalAnimate = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "animate"); + const animate = vi.fn(() => { + throw new Error("animation setup failed"); + }); + Object.defineProperty(HTMLElement.prototype, "animate", { + configurable: true, + value: animate, + }); + + try { + const { container } = render( + {getTaskStateIcon("IN_PROGRESS")}, + ); + const wrapper = container.querySelector(SPIN_SELECTOR) as HTMLElement; + expect(animate).toHaveBeenCalledOnce(); + expect(wrapper.style.animation).toBe(""); + } finally { + restoreProperty(HTMLElement.prototype, "animate", originalAnimate); + } + }); +}); + +function setDocumentVisibility(state: DocumentVisibilityState) { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: state, + }); + document.dispatchEvent(new Event("visibilitychange")); +} + +function restoreProperty( + target: object, + property: string, + descriptor: PropertyDescriptor | undefined, +) { + if (descriptor) Object.defineProperty(target, property, descriptor); + else Reflect.deleteProperty(target, property); +} diff --git a/apps/web/lib/ui/persistent-motion-visibility.test.tsx b/apps/web/lib/ui/persistent-motion-visibility.test.tsx new file mode 100644 index 0000000000..938c6fd936 --- /dev/null +++ b/apps/web/lib/ui/persistent-motion-visibility.test.tsx @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPersistentMotionVisibility } from "@kandev/ui/persistent-motion-visibility"; + +type MockIntersectionObserverInstance = { + callback: IntersectionObserverCallback; + disconnect: ReturnType; + observe: ReturnType; + emit: (isIntersecting: boolean, intersectionRatio?: number) => void; +}; + +const intersectionObservers: MockIntersectionObserverInstance[] = []; +const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); + +class MockIntersectionObserver { + callback: IntersectionObserverCallback; + disconnect = vi.fn(); + observe = vi.fn(); + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + intersectionObservers.push(this); + } + + emit(isIntersecting: boolean, intersectionRatio = isIntersecting ? 1 : 0) { + this.callback( + [ + { + isIntersecting, + intersectionRatio, + } as IntersectionObserverEntry, + ], + this as unknown as IntersectionObserver, + ); + } +} + +function makeAnimation() { + let playState: AnimationPlayState = "running"; + return { + get playState() { + return playState; + }, + pause: vi.fn(() => { + playState = "paused"; + }), + play: vi.fn(() => { + playState = "running"; + }), + } as unknown as Animation; +} + +function setDocumentVisibility(state: DocumentVisibilityState) { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: state, + }); + document.dispatchEvent(new Event("visibilitychange")); +} + +afterEach(() => { + intersectionObservers.length = 0; + vi.unstubAllGlobals(); + if (originalVisibilityState) { + Object.defineProperty(document, "visibilityState", originalVisibilityState); + } else { + Reflect.deleteProperty(document, "visibilityState"); + } + vi.restoreAllMocks(); +}); + +describe("createPersistentMotionVisibility", () => { + it("pauses owned Web Animations and CSS fallback when the document is hidden", () => { + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + const target = document.createElement("div"); + const element = document.createElement("span"); + element.style.animationPlayState = "running"; + const animation = makeAnimation(); + const controller = createPersistentMotionVisibility(target); + controller.register(element, animation); + + setDocumentVisibility("hidden"); + + expect(animation.pause).toHaveBeenCalledOnce(); + expect(element.style.animationPlayState).toBe("paused"); + + setDocumentVisibility("visible"); + + expect(animation.play).toHaveBeenCalledOnce(); + expect(element.style.animationPlayState).toBe("running"); + controller.dispose(); + }); + + it("pauses offscreen motion and resumes a newly registered effect", () => { + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + const target = document.createElement("div"); + const element = document.createElement("span"); + const hiddenElement = document.createElement("span"); + const firstAnimation = makeAnimation(); + const controller = createPersistentMotionVisibility(target); + controller.register(element, firstAnimation); + + intersectionObservers[0]?.emit(false); + expect(firstAnimation.pause).toHaveBeenCalledOnce(); + + const hiddenAnimation = makeAnimation(); + controller.register(hiddenElement, hiddenAnimation); + expect(hiddenAnimation.pause).toHaveBeenCalledOnce(); + + intersectionObservers[0]?.emit(true); + + expect(firstAnimation.play).toHaveBeenCalledOnce(); + expect(hiddenAnimation.play).toHaveBeenCalledOnce(); + controller.dispose(); + }); + + it("combines owner visibility with document and intersection state", () => { + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + const target = document.createElement("div"); + const element = document.createElement("span"); + const animation = makeAnimation(); + const controller = createPersistentMotionVisibility(target, { ownerVisible: false }); + controller.register(element, animation); + + expect(animation.pause).toHaveBeenCalledOnce(); + controller.setOwnerVisible(true); + expect(animation.play).toHaveBeenCalledOnce(); + + setDocumentVisibility("hidden"); + controller.setOwnerVisible(false); + setDocumentVisibility("visible"); + expect(animation.play).toHaveBeenCalledOnce(); + + controller.setOwnerVisible(true); + expect(animation.play).toHaveBeenCalledTimes(2); + controller.dispose(); + }); + + it("rejects duplicate registrations without changing the original CSS state", () => { + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + const target = document.createElement("div"); + const element = document.createElement("span"); + const controller = createPersistentMotionVisibility(target); + const registration = controller.register(element); + + intersectionObservers[0]?.emit(false); + expect(element.style.animationPlayState).toBe("paused"); + expect(() => controller.register(element)).toThrow(); + + intersectionObservers[0]?.emit(true); + expect(element.style.animationPlayState).toBe(""); + registration.unregister(); + controller.dispose(); + }); + + it("treats an intersecting zero-ratio edge target as visible", () => { + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + const target = document.createElement("div"); + const element = document.createElement("span"); + const animation = makeAnimation(); + const controller = createPersistentMotionVisibility(target); + controller.register(element, animation); + + intersectionObservers[0]?.emit(false); + intersectionObservers[0]?.emit(true, 0); + + expect(controller.isVisible()).toBe(true); + expect(animation.pause).toHaveBeenCalledOnce(); + expect(animation.play).toHaveBeenCalledOnce(); + controller.dispose(); + }); + + it("keeps visible feedback usable without IntersectionObserver and cleans up", () => { + vi.stubGlobal("IntersectionObserver", undefined); + const target = document.createElement("div"); + const element = document.createElement("span"); + const animation = makeAnimation(); + const controller = createPersistentMotionVisibility(target); + const registration = controller.register(element, animation); + + expect(animation.pause).not.toHaveBeenCalled(); + setDocumentVisibility("hidden"); + expect(animation.pause).toHaveBeenCalledOnce(); + + registration.unregister(); + controller.dispose(); + setDocumentVisibility("visible"); + expect(animation.play).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/lib/ui/state-icons.test.tsx b/apps/web/lib/ui/state-icons.test.tsx index 9d8acf6aff..1aeadc445c 100644 --- a/apps/web/lib/ui/state-icons.test.tsx +++ b/apps/web/lib/ui/state-icons.test.tsx @@ -65,7 +65,9 @@ describe("getTaskStateIcon", () => { } } }); +}); +describe("getTaskStateIcon", () => { it("animates an HTML wrapper while keeping the status SVG static", () => { const { container } = render( {getTaskStateIcon("IN_PROGRESS")}, diff --git a/docs/plans/frontend-rendering-cpu/evidence.md b/docs/plans/frontend-rendering-cpu/evidence.md new file mode 100644 index 0000000000..90a841b0d5 --- /dev/null +++ b/docs/plans/frontend-rendering-cpu/evidence.md @@ -0,0 +1,106 @@ +# Rendering attribution evidence + +## Capture + +- Fixture: disposable Chromium E2E task with one active task and one Quick Chat + task, both running the slow mock command. +- Runner: Chromium project, one shard and one worker, Playwright 1.61.1. +- Host capacity: 11 logical CPUs were visible to the runner. The capture does + not use a fixed CPU percentage gate. +- Protocol: one second settle, then three 8.34-second windows per repeated arm. +- Command: + + ```bash + (cd apps/web && KANDEV_E2E_ANIMATION_TRACE=1 \ + KANDEV_E2E_ANIMATION_TRACE_REPEATS=3 \ + rtk pnpm e2e:run --project chromium \ + tests/chat/animation-performance-trace.spec.ts) + ``` + +The trace test records event counts and summed event durations. Nested trace +events are not added as a CPU total. + +## Target inventory + +The normal-page inventory found the host-owned targets used by the isolation +arms: + +- `.spinner-grid-cube`: 18 visible CSS/animation targets in the fixture. +- `[data-testid=chat-input-glow].chat-input-glow-running`: the active composer + pulse target. +- CSS fallback keyframes: `spinner-grid` and `chat-input-glow-pulse`. +- Other transient inventory entries included sidebar fade/collapse and dialog + enter animations. No node-level target from the supplied trace was added to + the production repair list. + +The inventory also inspects `::before` and `::after` computed styles and the +document Web Animations list. It is diagnostic only and is not used to pause +arbitrary application animations in production. + +## Three-run summaries + +Values are shown as median with the observed minimum and maximum in brackets. +Target invalidations are counts of matching invalidation events. + +| Arm | UpdateLayoutTree count | Layerize count | Target invalidations | Grid / pulse invalidations | +| -------------------- | ---------------------: | -------------: | -------------------: | ------------------------------------: | +| Normal page | 6 [0, 6] | 3 [0, 3] | 76 [0, 114] | 72 [0, 108] / 4 [0, 6] | +| CSS fallback control | 272 [235, 272] | 239 [218, 244] | 5,168 [4,465, 5,168] | 4,896 [4,230, 4,896] / 272 [235, 272] | +| Grid paused | 297 [289, 334] | 279 [275, 303] | 297 [289, 334] | 0 / 297 [289, 334] | +| Pulse paused | 32 [31, 33] | 30 [29, 33] | 576 [558, 594] | 576 [558, 594] / 0 | +| All motion paused | 1 [0, 1] | 1 [0, 1] | 0 [0, 0] | 0 / 0 | + +The script-disabled compositor control recorded zero target invalidations. The +normal-page trace includes a zero-event first window, which is retained in the +range rather than discarded. CSS fallback target animations were verified as +running before and after every isolation arm. + +## Attribution result + +The isolation control is working: pausing the grid removes grid invalidations, +pausing the pulse removes pulse invalidations, and pausing all motion removes +the matched invalidation stream. This attributes the fixture's own fallback +work to those two targets. + +It does not attribute the supplied 5.57-second production trace's persistent +Layerize work. That trace lacks node-level style invalidation and already +running-animation attribution, and the small disposable fixture does not +reproduce its continuous Layerize cadence. Task 03 therefore remains pending; +no speculative production repair was made. + +The separate confirmed context-ring issue remains bounded to its short value +transition. Task 02 addresses that contract independently. + +## Post-integration verification + +An earlier post-integration run on 2026-09-10 recorded normal-page medians of +14 `UpdateLayoutTree` events, 12 `Layerize` events, and 228 matched target +invalidations. That run was retained as historical evidence; the controls were +subsequently hardened to keep targets alive and compare each paused group with +the matched script-enabled fallback baseline. + +## Review-remediation verification + +The three-repeat Chromium command passed on 2026-09-11 after the visibility and +trace-control remediation. Every arm retained 18 grid targets and one pulse +target. The normal compositor arm recorded zero target invalidations in all +three windows. The disabled-script CSS-fallback control recorded 646 target +invalidations at the median [608, 703], split into 612 grid [576, 666] and 34 +pulse [32, 37]. The matched script-enabled CSS-fallback baseline recorded 665 +target invalidations [627, 703], split into 630 grid [594, 666] and 35 pulse +[33, 37]. + +| Arm | Target invalidations | Grid / pulse invalidations | +| ----------------- | -------------------: | -------------------------: | +| CSS fallback baseline | 665 [627, 703] | 630 [594, 666] / 35 [33, 37] | +| Grid paused | 12 [11, 17] | 0 / 12 [11, 17] | +| Pulse paused | 468 [414, 504] | 468 [414, 504] / 0 | +| All motion paused | 0 [0, 180] | 0 [0, 180] / 0 | + +The paused group is suppressed against its matched script-enabled baseline in +each selective arm. The unpaused group remains active: pulse invalidations +continue in the grid-paused arm, and grid invalidations continue in the +pulse-paused arm. Residual invalidations in one all-motion window are retained +in the range rather than treated as a strict-zero gate; its median remains +below both baseline group medians. This verifies the bounded attribution +control without identifying the supplied trace's production rendering trigger. diff --git a/docs/plans/frontend-rendering-cpu/plan.md b/docs/plans/frontend-rendering-cpu/plan.md new file mode 100644 index 0000000000..4a021adb35 --- /dev/null +++ b/docs/plans/frontend-rendering-cpu/plan.md @@ -0,0 +1,190 @@ +--- +created: 2026-09-10 +status: in_progress +requirements: + - REQ-UI-PERSISTENT-STATUS-MOTION-001 + - REQ-UI-PERSISTENT-STATUS-MOTION-002 + - REQ-UI-PERSISTENT-STATUS-MOTION-003 + - REQ-UI-PERSISTENT-STATUS-MOTION-004 + - REQ-UI-PERSISTENT-STATUS-MOTION-005 +system_design: + - ../../specs/ui/system-design/persistent-status-motion.md +legacy_specs: [] +--- + +# Implementation Plan: Reduce Remaining Task Rendering CPU + +## Overview + +Identify the remaining continuous rendering trigger, make a targeted repair, +then stop spending resources on hidden status motion. Keep visible feedback +and task behavior intact. Implementation is in progress. + +UI owns the reusable motion and visibility contract. Task/session state, +transport, persistence, and plugin implementations retain their current owners. + +## Evidence and limits + +The supplied Trace-20260910T181155.json.gz contains 5.57 seconds for PID 2960. +It records 752 Layerize passes (1,079 ms), 213 ms PrePaint, 187 ms +UpdateLayoutTree, and 182 ms FunctionCall. Full steady seconds contain +139–144 Layerize passes. These event categories can nest and are not additive +CPU totals. Profiler startup contributes a separate one-time 249 ms. + +The trace proves rendering cost, but lacks the invalidation attribution needed +to identify the continuous trigger. Persistent animation is a hypothesis. +The context-ring scrollbar-color and stroke-dashoffset transitions are confirmed +but last only about 300 ms. Roughly 21,100 nodes do not establish a leak or +justify virtualization by themselves. + +The exact hashed asset in the capture contains the previous compositor fixes. +[Idle CPU](../frontend-idle-cpu/plan.md), +[animation CPU](../frontend-animation-cpu/plan.md), and +[runtime CPU](../frontend-runtime-cpu/plan.md) are completed packages. +Preserve their results; this package does not repeat those migrations. + +## Scope + +### In scope + +- Reproducible normal-page and animation-isolation captures. +- Narrow the context-ring transition. +- Repair the remaining attributed host-owned rendering trigger. +- Pause host-owned spin, grid, and pulse motion when hidden. +- Verify visible feedback, lifecycle recovery, and measured rendering results. + +### Out of scope + +- Transcript virtualization or unmounting retained editors and panels. +- Global animation disabling, slower visible feedback, or an energy setting. +- Backend, WebSocket, logging, and state-library changes. +- Editing Kandy or another production plugin inside this repository. +- Fixed cross-machine CPU percentages or a wall-clock performance CI gate. + +## Technical approach + +Use the proposed extension in the +[existing system design](../../specs/ui/system-design/persistent-status-motion.md#proposed-rendering-cpu-follow-up) +and its paired +[requirements](../../specs/ui/requirements/persistent-status-motion.md#proposed-follow-up-requirements). + +1. Extend the existing gated Chromium trace test. Its current script-disabled, + CSS-suppressed control cannot substitute for a normal-page baseline. +2. Restrict ContextWindowRing to the intended arc transition. +3. Use pause/restore attribution to select one minimal repair. Task 03 has an + evidence gate: populate exact target files and a failing regression from + Task 01 before changing production code. If the cause lies outside the + reviewed motion boundary, revise the design instead of guessing. +4. Share visibility lifecycle handling across existing motion primitives. + Preserve each primitive's timing, fallback, cleanup, and reduced-motion rules. + +Do not add an ADR: this proposal keeps current package, state, and persistence +boundaries. No public documentation change is expected because it adds no +setting, command, terminology, or user workflow. + +## ASCII UI preview + +UI-01: Existing task presentation, desktop and phone, active then settled. + +```text +Desktop: [Task list] | [Task chat: transcript] | [Changes] +Phone: [Task header] + [Chat transcript] + [Composer: activity glow; usage ring] + [Existing bottom navigation] + +Visible + active -> existing moving indicator +Hidden + active -> motion paused; task updates continue +Visible + settled -> existing settled indicator; no replay +Usage update -> ring arc moves; threshold color updates directly +``` + +This is a motion-only change. Existing grouping and navigation are structural +requirements; spacing above is illustrative. Desktop split panes that remain +visible keep motion. Phone keeps the existing Chat scroll owner, safe areas, +and touch controls. No new control or text is introduced. +Maps to AC-UI-PERSISTENT-STATUS-MOTION-004.1 through .5 and -005.1 through .3. + +## Tests + +- Existing token-usage-display.test.tsx: value, threshold color, and disclosure + regressions; rendered computed-transition checks prove -005.1 through .3. +- New lib/ui/persistent-motion-visibility.test.tsx: hidden mount, resume, + settled/unmount while hidden, missing observer, repeated toggles, and cleanup. +- Existing lib/ui/compositor-pulse.test.tsx, lib/ui/state-icons.test.tsx, and + components/grid-spinner.test.tsx: fallback, effect replacement, reduced motion, + and owned-handle pause/resume. Maps to -004.1 through .5 and existing -001/002/003. +- Task 03 adds the target-specific failing regression identified by Task 01. + Broad memoization or class-only assertions cannot prove the CPU repair. + +## E2E tests + +Extend existing chat/persistent-animation-motion.spec.ts and its mobile partner. +Cover visible busy motion, offscreen/panel pause, restoration, and settlement +while hidden. Include actual browser document-visibility evidence when the +runner supports it; a synthetic event alone cannot prove a hidden tab. +Use deterministic unit coverage for visibility event handling. + +Extend chat/context-window-source.spec.ts and its mobile partner for ring +updates, intended transition properties, and disclosure behavior. +Use chat/animation-performance-trace.spec.ts for normal-page and isolated +controls. It must retain target presence assertions to prevent a vacuous pass. + +## Work orders + +- [x] [Task 01: Attribute continuous rendering](task-01-attribute-rendering.md) +- [x] [Task 02: Bound context-ring transitions](task-02-bound-ring-transitions.md) +- [ ] [Task 03: Repair the attributed rendering trigger](task-03-repair-rendering-trigger.md) +- [x] [Task 04: Pause hidden status motion](task-04-pause-hidden-motion.md) + +Execute sequentially: 01 -> 02 -> 03 -> 04 when the evidence gate is met. +Task 03 requires evidence, not elapsed time. If no cause is reproduced, report +that result and keep it pending. Task 02 and the specified visibility work can +proceed without claiming the main CPU problem is solved. No subagent +authorization is implied. + +## Verification protocol + +Bootstrap a fresh worktree once with +`(cd apps && rtk pnpm install --frozen-lockfile)`. +Each work order lists its targeted commands, run from the repository root. +Managed E2E commands build production assets and use isolated data. +No production traces or real task transcripts are committed. + +For each performance arm, settle for one second and collect 8.34 seconds. +Repeat three times with identical browser, fixture, viewport, and display +cadence. Keep script execution enabled for the normal-page arms. Capture a +separate disabled-script control only to isolate CSS/compositor work. +Report target-level invalidations and page-level layer/style work separately. + +Success requires the reproduced target to stop causing recurring main-thread +work while visible motion remains correct. Hidden primitives must pause and +resume without extra effects or stale status. Record CPU medians and ranges; +a smaller token-ring transition alone does not satisfy the main repair. +If layerization persists, retain the open finding and document the next +attribution step. Investigate DOM-size controls before proposing virtualization. + +## Verification results + +Planning: specification linter tests passed (36 tests); all specification files +passed lint; documentation whitespace and local-link checks passed. +Task 01: three-repeat Chromium capture passed; see [evidence.md](evidence.md). +Task 02: bounded ring transition implemented and verified by unit plus desktop +and mobile browser tests. +Task 03: remains pending because the fixture isolation did not reproduce the +supplied trace's persistent Layerize cadence. +Task 04: shared visibility lifecycle implemented for spin, grid, and pulse; +focused unit tests, desktop/mobile persistent-motion tests (including a real +scroll across the intersection boundary), desktop/mobile Quick Chat tests, and +the review-remediation three-repeat trace passed. The trace compares each +paused group with a matched script-enabled baseline and keeps the unpaused +group active. See [evidence.md](evidence.md). + +## Risks + +- Browser compositor behavior varies with browser build, refresh rate, and DOM. +- Existing trace controls can hide a real JavaScript invalidation source. +- Intersection observation can miss owner-specific hidden states; use existing + visibility ownership without treating visible split panes as inactive. +- Pausing fallback CSS must not suppress later visible or reduced-motion states. +- The attached trace cannot establish an exact production repair in advance. diff --git a/docs/plans/frontend-rendering-cpu/task-01-attribute-rendering.md b/docs/plans/frontend-rendering-cpu/task-01-attribute-rendering.md new file mode 100644 index 0000000000..964d9473ea --- /dev/null +++ b/docs/plans/frontend-rendering-cpu/task-01-attribute-rendering.md @@ -0,0 +1,87 @@ +--- +id: "01-attribute-rendering" +title: "Attribute continuous rendering" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-PERSISTENT-STATUS-MOTION-002 + - REQ-UI-PERSISTENT-STATUS-MOTION-003 +acceptance_criteria: + - AC-UI-PERSISTENT-STATUS-MOTION-002.3 + - AC-UI-PERSISTENT-STATUS-MOTION-003.3 +system_design: + - ../../specs/ui/system-design/persistent-status-motion.md +--- + +# Task 01: Attribute continuous rendering + +## Summary + +Produce a repeatable reproduction and identify the target that keeps the main thread rendering. Do not change production code in this work order. + +## In scope + +Extend the gated trace fixture with normal-script baseline, all-motion paused, restoration, and per-group isolation arms. Inventory CSS and Web Animations, including pseudo-elements. Reload the isolated fixture between arms or restore exact play states and inline declarations in finally. Assert no active motion remains in the pause control; detect animations created after the first inventory. Preserve the existing narrow compositor and CSS-fallback controls. + +## Out of scope + +Backend/state changes, plugin implementation changes, transcript virtualization, +and unmounting user content. No global production animation suppression. + +## Acceptance + +Record three equal windows per arm, environment details, target identities, CPU availability, event counts/durations, and node-level invalidations. A target is attributed only when pausing removes the recurring work and restoration reproduces it. Add the exact target files, regression, and commands to Task 03 before production repair; if not reproduced, record the limitation and keep that task pending. + +## Verification + +From the repository root, after the plan's one-time dependency bootstrap: + +```bash +(cd apps/web && KANDEV_E2E_ANIMATION_TRACE=1 rtk pnpm e2e:run --project chromium tests/chat/animation-performance-trace.spec.ts) +``` + +Record actual discovery counts, failures, and results. Do not substitute a +skipped trace run for performance evidence. Implementation uses TDD for changed +logic; the attribution work order produces diagnostic evidence first. + +## Files likely touched + +- `apps/web/e2e/tests/chat/animation-performance-trace.spec.ts` +- `apps/web/e2e/helpers/animation-assertions.ts` +- `docs/plans/frontend-rendering-cpu/evidence.md (new sanitized report)` +- `docs/plans/frontend-rendering-cpu/task-03-repair-rendering-trigger.md` + +## Dependencies + +None. See the manifest for evidence gates. + +## Risks + +The existing small fixture may not reproduce the user's long transcript or high refresh rate. Seed disposable realistic content and compare a smaller-DOM control; do not silently call a non-reproduction a pass. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/persistent-status-motion.md). +- [Design](../../specs/ui/system-design/persistent-status-motion.md). +- Existing animation trace, component tests, and desktop/mobile motion specs. +- Baseline findings in [plan.md](plan.md#evidence-and-limits). + +## Results + +Done. The gated trace fixture now records normal-page, compositor-control, +CSS-fallback, all-motion, and per-target pause/restore arms. It inventories CSS +animations, pseudo-elements, and Web Animations, retains all configured +repeats, and reports medians and ranges without adding nested event durations. + +The three-repeat Chromium run passed after the trace-control remediation. The +fixture identifies grid and composer pulse invalidations with targets kept +present, compares selective pause arms against a matched script-enabled +baseline, and verifies continued activity in the unpaused group. The supplied +production trace's persistent Layerize work remains unattributed. See +[evidence.md](evidence.md). Task 03 remains pending under its evidence gate. diff --git a/docs/plans/frontend-rendering-cpu/task-02-bound-ring-transitions.md b/docs/plans/frontend-rendering-cpu/task-02-bound-ring-transitions.md new file mode 100644 index 0000000000..71fedfe0c2 --- /dev/null +++ b/docs/plans/frontend-rendering-cpu/task-02-bound-ring-transitions.md @@ -0,0 +1,100 @@ +--- +id: "02-bound-ring-transitions" +title: "Bound context-ring transitions" +status: done +wave: 2 +depends_on: + - "01-attribute-rendering" +plan: "plan.md" +requirements: + - REQ-UI-PERSISTENT-STATUS-MOTION-005 +acceptance_criteria: + - AC-UI-PERSISTENT-STATUS-MOTION-005.1 + - AC-UI-PERSISTENT-STATUS-MOTION-005.2 + - AC-UI-PERSISTENT-STATUS-MOTION-005.3 +system_design: + - ../../specs/ui/system-design/persistent-status-motion.md +--- + +# Task 02: Bound context-ring transitions + +## Summary + +Remove accidental context-ring scrollbar and color transitions. Keep the existing value, geometry, 300 ms easing, and disclosure behavior. + +## In scope + +Change only ContextWindowRing's transition property to stroke-dashoffset. Add a browser regression that changes usage across a color threshold and inspects computed transition properties and resulting arc. Retain existing context-source and touch disclosure cases. Begin with a failing rendered assertion against transition-all. + +## Out of scope + +Backend/state changes, plugin implementation changes, transcript virtualization, +and unmounting user content. No global production animation suppression. + +## Acceptance + +The ring reaches the new usage value and threshold color. No scrollbar-color transition or unrelated transition is created. Desktop and mobile disclosure behavior and geometry remain intact. + +## ASCII UI preview + +UI-01, excerpt from the [combined preview](plan.md#ascii-ui-preview): + +```text +Desktop / phone existing task surface: +Visible active -> motion runs +Hidden active -> motion pauses, state remains current +Visible settled -> settled status +Context ring -> arc transition only +``` + +Preserve the existing desktop panels and dedicated phone Chat composition. +The work order's frontmatter ACs map this preview to its rendered checks. + +## Verification + +From the repository root, after the plan's one-time dependency bootstrap: + +```bash +(cd apps/web && rtk pnpm test components/task/chat/token-usage-display.test.tsx) +(cd apps/web && rtk pnpm e2e:run --project chromium tests/chat/context-window-source.spec.ts) +(cd apps/web && rtk pnpm e2e:run --project mobile-chrome tests/chat/mobile-context-window-source.spec.ts) +``` + +Record actual discovery counts, failures, and results. Do not substitute a +skipped trace run for performance evidence. Implementation uses TDD for changed +logic; the attribution work order produces diagnostic evidence first. + +## Files likely touched + +- `apps/web/components/task/chat/token-usage-display.tsx` +- `apps/web/components/task/chat/token-usage-display.test.tsx` +- `apps/web/e2e/tests/chat/context-window-source.spec.ts` +- `apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts` + +## Dependencies + +01-attribute-rendering. See the manifest for evidence gates. + +## Risks + +SVG stroke motion still uses rendering work during the bounded transition. Do not describe this as the main CPU fix. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/persistent-status-motion.md). +- [Design](../../specs/ui/system-design/persistent-status-motion.md). +- Existing animation trace, component tests, and desktop/mobile motion specs. +- Baseline findings in [plan.md](plan.md#evidence-and-limits). + +## Results + +The ring now transitions only `stroke-dashoffset` while preserving its 300 ms +duration, easing, geometry, threshold colors, and disclosure behavior. The +unit suite passed with 14 tests. The desktop context-source suite passed with +3 tests, and the mobile context-source suite passed with 3 tests. Task 01 +evidence is recorded in [evidence.md](evidence.md); no production rendering +target has been selected for Task 03. diff --git a/docs/plans/frontend-rendering-cpu/task-03-repair-rendering-trigger.md b/docs/plans/frontend-rendering-cpu/task-03-repair-rendering-trigger.md new file mode 100644 index 0000000000..85d0393bfb --- /dev/null +++ b/docs/plans/frontend-rendering-cpu/task-03-repair-rendering-trigger.md @@ -0,0 +1,105 @@ +--- +id: "03-repair-rendering-trigger" +title: "Repair the attributed rendering trigger" +status: pending +wave: 3 +depends_on: + - "01-attribute-rendering" + - "02-bound-ring-transitions" +plan: "plan.md" +requirements: + - REQ-UI-PERSISTENT-STATUS-MOTION-001 + - REQ-UI-PERSISTENT-STATUS-MOTION-002 + - REQ-UI-PERSISTENT-STATUS-MOTION-003 +acceptance_criteria: + - AC-UI-PERSISTENT-STATUS-MOTION-001.1 + - AC-UI-PERSISTENT-STATUS-MOTION-001.4 + - AC-UI-PERSISTENT-STATUS-MOTION-002.3 + - AC-UI-PERSISTENT-STATUS-MOTION-003.3 +system_design: + - ../../specs/ui/system-design/persistent-status-motion.md +--- + +# Task 03: Repair the attributed rendering trigger + +## Summary + +Eliminate the reproduced host-owned continuous rendering trigger while preserving visible motion. This work order is evidence-gated until Task 01 identifies the target. + +## In scope + +Restrict the repair to the target and existing motion boundaries identified in Task 01. Reuse compositor spin, grid, or pulse mechanisms where applicable. Add a failing behavioral or node-attributed trace regression before changing production. Update the design and this work order with exact ownership and targeted test commands before execution; do not treat the candidate list as blanket edit scope. + +## Out of scope + +Backend/state changes, plugin implementation changes, transcript virtualization, +and unmounting user content. No global production animation suppression. + +## Acceptance + +Pause/restore evidence identifies a specific target. A pre-fix regression fails for its recurring invalidations and passes after repair. Normal-page before/after captures demonstrate removal of the attributed recurring work with visible feedback, settlement, and mobile behavior preserved. + +## ASCII UI preview + +UI-01, excerpt from the [combined preview](plan.md#ascii-ui-preview): + +```text +Desktop / phone existing task surface: +Visible active -> motion runs +Hidden active -> motion pauses, state remains current +Visible settled -> settled status +Context ring -> arc transition only +``` + +Preserve the existing desktop panels and dedicated phone Chat composition. +The work order's frontmatter ACs map this preview to its rendered checks. + +## Verification + +From the repository root, after the plan's one-time dependency bootstrap: + +```bash +(cd apps/web && rtk pnpm test lib/ui/state-icons.test.tsx lib/ui/compositor-pulse.test.tsx components/grid-spinner.test.tsx) +(cd apps/web && rtk pnpm e2e:run --project chromium tests/chat/persistent-animation-motion.spec.ts) +(cd apps/web && rtk pnpm e2e:run --project mobile-chrome tests/chat/mobile-persistent-animation-motion.spec.ts) +(cd apps/web && KANDEV_E2E_ANIMATION_TRACE=1 rtk pnpm e2e:run --project chromium tests/chat/animation-performance-trace.spec.ts) +``` + +Record actual discovery counts, failures, and results. Do not substitute a +skipped trace run for performance evidence. Implementation uses TDD for changed +logic; the attribution work order produces diagnostic evidence first. + +## Files likely touched + +- `apps/packages/ui/src/compositor-spin.tsx (candidate only)` +- `apps/packages/ui/src/compositor-pulse.tsx (candidate only)` +- `apps/web/components/grid-spinner.tsx (candidate only)` +- `apps/web/app/globals.css (candidate only)` +- `apps/web/e2e/tests/chat/animation-performance-trace.spec.ts` + +## Dependencies + +01-attribute-rendering, 02-bound-ring-transitions. See the manifest for evidence gates. + +## Risks + +The exact source is unknown. If it is plugin-owned, outside status motion, or requires transcript virtualization, record the finding and revise the relevant design before proceeding. No speculative memoization or new rendering architecture. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/persistent-status-motion.md). +- [Design](../../specs/ui/system-design/persistent-status-motion.md). +- Existing animation trace, component tests, and desktop/mobile motion specs. +- Baseline findings in [plan.md](plan.md#evidence-and-limits). + +## Results + +Pending by design. Task 01 isolates the disposable fixture's grid and composer +pulse invalidations, but the supplied trace's recurring Layerize work is not +attributed to a production node. The evidence gate therefore prevents a +speculative repair. Reopen this work order only after a target-specific trace +and failing regression identify the source. diff --git a/docs/plans/frontend-rendering-cpu/task-04-pause-hidden-motion.md b/docs/plans/frontend-rendering-cpu/task-04-pause-hidden-motion.md new file mode 100644 index 0000000000..f540cc0061 --- /dev/null +++ b/docs/plans/frontend-rendering-cpu/task-04-pause-hidden-motion.md @@ -0,0 +1,125 @@ +--- +id: "04-pause-hidden-motion" +title: "Pause hidden status motion" +status: done +wave: 4 +depends_on: + - "03-repair-rendering-trigger" +plan: "plan.md" +requirements: + - REQ-UI-PERSISTENT-STATUS-MOTION-004 +acceptance_criteria: + - AC-UI-PERSISTENT-STATUS-MOTION-004.1 + - AC-UI-PERSISTENT-STATUS-MOTION-004.2 + - AC-UI-PERSISTENT-STATUS-MOTION-004.3 + - AC-UI-PERSISTENT-STATUS-MOTION-004.4 + - AC-UI-PERSISTENT-STATUS-MOTION-004.5 +system_design: + - ../../specs/ui/system-design/persistent-status-motion.md +--- + +# Task 04: Pause hidden status motion + +## Summary + +Pause host-owned persistent status motion while its target is hidden. Resume the current active state without losing updates or replaying settled activity. + +## In scope + +Add a shared lifecycle helper for owned animation handles and integrate spin, pulse, and grid. Observe stable target wrappers, combine document/intersection visibility, and use existing panel visibility where needed. Apply scoped CSS fallback pause/resume. Preserve prior inline styles, cleanup, reduced-motion behavior, and grid stagger. Test mount-hidden, visibility changes, effect replacement, unmount, and settlement while hidden. + +## Out of scope + +Backend/state changes, plugin implementation changes, transcript virtualization, +and unmounting user content. No global production animation suppression. + +## Acceptance + +Hidden targets have no running owned effects; visible active targets resume with no duplicate handles. Current state and all non-motion UI behavior survive hidden intervals. Desktop/mobile browser checks and three matched final trace captures record the resulting behavior and performance. + +## ASCII UI preview + +UI-01, excerpt from the [combined preview](plan.md#ascii-ui-preview): + +```text +Desktop / phone existing task surface: +Visible active -> motion runs +Hidden active -> motion pauses, state remains current +Visible settled -> settled status +Context ring -> arc transition only +``` + +Preserve the existing desktop panels and dedicated phone Chat composition. +The work order's frontmatter ACs map this preview to its rendered checks. + +## Verification + +From the repository root, after the plan's one-time dependency bootstrap: + +```bash +(cd apps/web && rtk pnpm test lib/ui/persistent-motion-visibility.test.tsx lib/ui/compositor-pulse.test.tsx lib/ui/state-icons.test.tsx components/grid-spinner.test.tsx) +(cd apps/web && rtk pnpm run typecheck) +(cd apps/web && rtk pnpm e2e:run --project chromium tests/chat/persistent-animation-motion.spec.ts tests/chat/quick-chat-idle-dot.spec.ts) +(cd apps/web && rtk pnpm e2e:run --project mobile-chrome tests/chat/mobile-persistent-animation-motion.spec.ts tests/chat/mobile-quick-chat-idle-dot.spec.ts) +(cd apps/web && KANDEV_E2E_ANIMATION_TRACE=1 rtk pnpm e2e:run --project chromium tests/chat/animation-performance-trace.spec.ts) +rtk proxy python3 scripts/lint-spec-files.py --all +rtk git diff --check +``` + +Record actual discovery counts, failures, and results. Do not substitute a +skipped trace run for performance evidence. Implementation uses TDD for changed +logic; the attribution work order produces diagnostic evidence first. + +## Files likely touched + +- `apps/packages/ui/src/persistent-motion-visibility.tsx (new)` +- `apps/packages/ui/src/compositor-spin.tsx` +- `apps/packages/ui/src/compositor-pulse.tsx` +- `apps/web/components/grid-spinner.tsx` +- `apps/web/lib/ui/persistent-motion-visibility.test.tsx (new)` +- `apps/web/lib/ui/compositor-pulse.test.tsx` +- `apps/web/lib/ui/compositor-spin.test.tsx` +- `apps/web/lib/ui/state-icons.test.tsx` +- `apps/web/components/grid-spinner.test.tsx` +- `apps/web/e2e/helpers/animation-assertions.ts` +- `apps/web/e2e/tests/chat/persistent-animation-motion.spec.ts` +- `apps/web/e2e/tests/chat/mobile-persistent-animation-motion.spec.ts` +- `apps/web/e2e/tests/chat/animation-performance-trace.spec.ts` +- `apps/web/e2e/tests/chat/context-window-source.spec.ts` +- `apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts` + +## Dependencies + +03-repair-rendering-trigger. See the manifest for evidence gates. + +## Risks + +A hidden document is not the same as a blurred window. Intersection alone may not model all panel visibility. Unsupported observers must not freeze visible feedback. If Task 03 is unreproduced, this bounded work may proceed with that limitation recorded, but the package remains incomplete. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/persistent-status-motion.md). +- [Design](../../specs/ui/system-design/persistent-status-motion.md). +- Existing animation trace, component tests, and desktop/mobile motion specs. +- Baseline findings in [plan.md](plan.md#evidence-and-limits). + +## Results + +Implemented the shared visibility controller in +`apps/packages/ui/src/persistent-motion-visibility.tsx` and integrated it with +the compositor spin, pulse, and grid primitives. It combines document and +intersection visibility, pauses owned Web Animations and CSS fallbacks, resumes +only active effects, and cleans up shared listeners and observers. + +Focused unit coverage passed with 90 tests, including the visibility helper, +spin, pulse, grid, and ring regressions. Desktop and mobile persistent-motion +browser tests passed with 1 test each, including a real scroll across the +intersection boundary. Desktop and mobile Quick Chat indicator tests passed +with 2 tests each. The review-remediation three-repeat trace passed with +non-empty target assertions, a matched script-enabled fallback baseline, and +group-specific suppression checks. The supplied trace's production trigger +remains unidentified, so this work order does not claim to complete Task 03. diff --git a/docs/specs/ui/requirements/persistent-status-motion.md b/docs/specs/ui/requirements/persistent-status-motion.md index ea3465a2e5..a7d8b64cd8 100644 --- a/docs/specs/ui/requirements/persistent-status-motion.md +++ b/docs/specs/ui/requirements/persistent-status-motion.md @@ -2,7 +2,7 @@ status: active system: ui created: 2026-08-23 -updated: 2026-08-28 +updated: 2026-09-10 owners: - kandev --- @@ -106,9 +106,56 @@ keep pulsing, so that active work remains obvious without high steady CPU use. unavailable, each migrated surface shall retain its current animated CSS fallback and reduced-motion behavior. +## Proposed follow-up requirements + +The following additions are draft for the +[rendering CPU follow-up](../../../plans/frontend-rendering-cpu/plan.md). +Requirements 001 through 003 remain the accepted contract. Their visible-motion +criteria apply while the target is visible; the proposed lifecycle below +governs hidden targets. + +### REQ-UI-PERSISTENT-STATUS-MOTION-004: Hidden motion lifecycle + +**Intent:** Avoid spending rendering resources on status motion the user cannot see. + +#### Acceptance criteria + +- **AC-UI-PERSISTENT-STATUS-MOTION-004.1:** When the document is hidden, its + host-owned persistent rotation, grid, and pulse animations shall pause. +- **AC-UI-PERSISTENT-STATUS-MOTION-004.2:** When a status target is outside its + scroll viewport or in an inactive hidden panel, its motion shall pause. +- **AC-UI-PERSISTENT-STATUS-MOTION-004.3:** When the target becomes visible, + motion shall resume only if its current status still requires motion. A status + that settled while hidden shall appear settled without replaying old motion. +- **AC-UI-PERSISTENT-STATUS-MOTION-004.4:** Pausing motion shall preserve task + updates, transcript content, draft input, focus, scroll position, accessible + status, and existing reduced-motion behavior on desktop and mobile. +- **AC-UI-PERSISTENT-STATUS-MOTION-004.5:** When visibility observation is + unavailable, visible status feedback shall remain usable through the existing + fallback. Repeated visibility changes shall not accumulate live animations. + +### REQ-UI-PERSISTENT-STATUS-MOTION-005: Bounded context-ring motion + +**Intent:** Animate changes in context usage without animating unrelated styles. + +#### Acceptance criteria + +- **AC-UI-PERSISTENT-STATUS-MOTION-005.1:** When context usage changes, the ring + shall reach the new value with its existing 300 ms easing and geometry. +- **AC-UI-PERSISTENT-STATUS-MOTION-005.2:** The ring shall animate only its + usage arc. Color and inherited scrollbar styles shall not start transitions. +- **AC-UI-PERSISTENT-STATUS-MOTION-005.3:** Desktop and phone shall retain the + same usage value, threshold colors, accessible disclosure, and touch behavior. + +## Implementation plans + +- [Idle CPU (completed)](../../../plans/frontend-idle-cpu/plan.md) +- [Animation CPU (completed)](../../../plans/frontend-animation-cpu/plan.md) +- [Rendering CPU follow-up (draft)](../../../plans/frontend-rendering-cpu/plan.md) + ## Out of scope -- Removing status motion. +- Removing visible status motion. - Changing task, session, or run state rules. - Changing reduced-motion behavior. - Migrating one-shot entrance, selection, search, or confirmation cues. diff --git a/docs/specs/ui/system-design/persistent-status-motion.md b/docs/specs/ui/system-design/persistent-status-motion.md index 866ddf5000..054c730130 100644 --- a/docs/specs/ui/system-design/persistent-status-motion.md +++ b/docs/specs/ui/system-design/persistent-status-motion.md @@ -5,6 +5,8 @@ requirements: - REQ-UI-PERSISTENT-STATUS-MOTION-001 - REQ-UI-PERSISTENT-STATUS-MOTION-002 - REQ-UI-PERSISTENT-STATUS-MOTION-003 + - REQ-UI-PERSISTENT-STATUS-MOTION-004 + - REQ-UI-PERSISTENT-STATUS-MOTION-005 --- # Persistent Status Motion System Design @@ -24,11 +26,11 @@ One-shot attention and transition effects remain outside this design. ## Requirement mapping -| Requirement | Design section | -| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `REQ-UI-PERSISTENT-STATUS-MOTION-001` | [Motion primitive](#motion-primitive), [Status surfaces](#status-surfaces), [Desktop and mobile composition](#desktop-and-mobile-composition) | +| Requirement | Design section | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQ-UI-PERSISTENT-STATUS-MOTION-001` | [Motion primitive](#motion-primitive), [Status surfaces](#status-surfaces), [Desktop and mobile composition](#desktop-and-mobile-composition) | | `REQ-UI-PERSISTENT-STATUS-MOTION-002` | [Grid activity motion](#grid-activity-motion), [Animation lifecycle and fallback](#animation-lifecycle-and-fallback), [Verification and performance evidence](#verification-and-performance-evidence) | -| `REQ-UI-PERSISTENT-STATUS-MOTION-003` | [Opacity pulse motion](#opacity-pulse-motion), [Desktop and mobile composition](#desktop-and-mobile-composition), [Verification and performance evidence](#verification-and-performance-evidence) | +| `REQ-UI-PERSISTENT-STATUS-MOTION-003` | [Opacity pulse motion](#opacity-pulse-motion), [Desktop and mobile composition](#desktop-and-mobile-composition), [Verification and performance evidence](#verification-and-performance-evidence) | ## Motion primitive @@ -200,3 +202,85 @@ and restores CSS for all nine cells so one grid never mixes timing engines. The wrapper preserves existing test IDs and semantic attributes. Tests and assistive technology do not need to depend on the nested SVG element. + +## Proposed rendering CPU follow-up + +This section is draft. Earlier sections describe the accepted implementation. +The [follow-up plan](../../../plans/frontend-rendering-cpu/plan.md) owns delivery +and the outstanding attribution investigation. + +### Mapping + +| Requirement | Proposed boundary | +| ----------------------------------- | ----------------------------- | +| REQ-UI-PERSISTENT-STATUS-MOTION-004 | Visibility lifecycle below | +| REQ-UI-PERSISTENT-STATUS-MOTION-005 | Context-ring transition below | + +### Visibility lifecycle + +Keep animation ownership in `CompositorSpin`, `CompositorPulse`, and +`GridSpinner`. Introduce a small shared lifecycle helper in +`apps/packages/ui/src/` for their owned animation handles. It must not scan or +pause arbitrary document animations in production. Diagnostic controls may do +so only inside the isolated performance fixture. + +Combine document visibility with target intersection and, where geometry alone +does not reflect a hidden panel, the existing owner visibility signal. The +desktop panel layer already uses `usePanelActive` in +`apps/web/components/task/dockview-panel-content.tsx`. Do not equate an unfocused +but visible split pane with a hidden pane. Inspect owner semantics before wiring. + +Use visibility and intersection events, not polling or a frame loop. Observe +the stable outer wrapper, not an animated zero-scale grid cell. A grid shares +one observation across its nine effects. Reuse a document subscription across +targets if the audit shows many instances; do not notify unrelated app state. +This helper belongs to the UI package and must not import the web store. + +When ineligible, pause owned Web Animations handles; resume existing handles +when eligible. Apply equivalent scoped `animation-play-state` handling to CSS +fallbacks. Restore prior inline declarations on cleanup. A new effect created +while hidden starts paused, including after a class or reduced-motion change. +Cancel old handles before replacement. Disconnect observers and listeners on +unmount. If observation is unavailable, assume intersection and retain document +visibility gating. Preserve each primitive's existing reduced-motion policy. + +Domain updates and subscriptions continue while presentation motion pauses. +Do not unmount editors, transcripts, terminals, or panels to pause an indicator. +Do not introduce settings, persistence, backend changes, or plugin controls. + +### Context-ring transition + +`ContextWindowRing` in `token-usage-display.tsx` uses `transition-all`. Restrict +that circle to `stroke-dashoffset`, retaining 300 ms and the current easing. +Apply threshold color changes immediately. Do not change global scrollbar CSS +or the separate expanded usage bar. This bounds the confirmed transition cost; +it does not make SVG stroke animation compositor-backed. + +### Attribution and acceptance + +Extend `e2e/tests/chat/animation-performance-trace.spec.ts`. Its current capture +disables script execution and unrelated CSS motion. That proves a narrow +compositor control, not normal whole-page behavior. Retain that control, and add +a normal-script baseline plus CSS-and-Web-Animations pause and restoration arms. +Include target IDs, selectors, keyframes, play states, visibility, and trace +invalidation events. Match backend node IDs where available; do not rely only +on a class-name substring in serialized trace arguments. + +Compare equal settled windows on the same build, browser, display cadence, +viewport, data, and plugin state. Use three runs per arm. Report medians and +ranges, main-thread CPU when available, layer/style counts and durations, and +target invalidations. Never add nested event durations as independent CPU time. +Exclude profiler startup. Record controlled extension-free runs separately from +the user's Vivaldi recording and do not claim cross-machine percentage savings. + +Before a root-cause repair, identify an owned target whose pause removes the +recurring work and whose restoration brings it back. If that does not happen, +continue attribution to style invalidators rather than assuming animation is +the cause. A plugin cause belongs to its dedicated repository. Broad transcript +virtualization requires a separate design after a DOM-size control proves value. + +Desktop and phone retain their current task layouts. The mobile exemplar is +`task-layout.tsx` and `mobile-persistent-animation-motion.spec.ts`. Extend the +existing desktop/mobile motion tests for hidden-to-visible and settled-while- +hidden states. Preserve the existing scroll owner, safe areas, touch controls, +and no-horizontal-overflow assertions. No new user-facing copy is needed.