Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions apps/web/src/components/InteractiveReceipt.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useLayoutEffect, useMemo, useRef } from "react";
import type { SessionData } from "../lib/api";
import {
isReceiptSettled,
nextReceiptIdleFrame,
shouldSimulateReceiptFrame,
} from "../lib/interactive-receipt-frame-policy";
import { SMART_TAG_LABELS } from "./SmartTagChips";
import type { SessionDetailToc } from "./session-detail/toc";

Expand Down Expand Up @@ -557,10 +562,12 @@ export function InteractiveReceipt({
let lastMetrics: SheetMetrics | null = null;
let stableFrames = 0;
let idleFrames = 0;
let lastSimulationTime = 0;
let isVisible = false;
let anchorVisible = true;
let running = false;
const desktopMedia = window.matchMedia(minWidthQuery);
const reducedMotionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");

const shouldRun = () =>
desktopMedia.matches && document.visibilityState === "visible" && anchorVisible;
Expand All @@ -582,7 +589,7 @@ export function InteractiveReceipt({
if (isVisible === visible) return;
isVisible = visible;
canvas.style.visibility = visible ? "visible" : "hidden";
hitSurface.style.visibility = visible ? "visible" : "hidden";
hitSurface.style.visibility = visible && !reducedMotionMedia.matches ? "visible" : "hidden";
};

const stopLoop = () => {
Expand All @@ -609,6 +616,7 @@ export function InteractiveReceipt({
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
stableFrames = 0;
idleFrames = 0;
lastSimulationTime = 0;
setVisible(false);
resetSheet(getSheetMetrics());
if (shouldRun()) startLoop();
Expand All @@ -633,7 +641,7 @@ export function InteractiveReceipt({
};

const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return;
if (event.button !== 0 || reducedMotionMedia.matches) return;
const point = getPoint(event);
const target = findGrabTarget(sheet.particles, point.x, point.y);
if (target == null) return;
Expand Down Expand Up @@ -787,6 +795,11 @@ export function InteractiveReceipt({
stopLoop();
return;
}
if (!shouldSimulateReceiptFrame(time, lastSimulationTime)) {
animationFrame = window.requestAnimationFrame(tick);
return;
}
lastSimulationTime = time;

const metrics = getSheetMetrics();
const changed = metricsChanged(lastMetrics, metrics);
Expand All @@ -800,12 +813,15 @@ export function InteractiveReceipt({
}

setTopRowPins(sheet.particles, metrics);
integrate(time);
constrain();
if (!reducedMotionMedia.matches) {
integrate(time);
constrain();
}
draw();
idleFrames = pointer.id == null ? idleFrames + 1 : 0;
if (stableFrames >= 2) setVisible(true);
if (stableFrames >= 2 && idleFrames >= 90) {
idleFrames = nextReceiptIdleFrame(pointer.id != null, idleFrames);
const isReducedMotion = reducedMotionMedia.matches;
if (isReducedMotion || stableFrames >= 2) setVisible(true);
if (isReducedMotion || isReceiptSettled(stableFrames, idleFrames)) {
running = false;
animationFrame = 0;
return;
Expand All @@ -823,6 +839,7 @@ export function InteractiveReceipt({
});
intersectionObserver.observe(anchor);
desktopMedia.addEventListener("change", syncLoopState);
reducedMotionMedia.addEventListener("change", syncLoopState);
document.addEventListener("visibilitychange", syncLoopState);
window.addEventListener("resize", syncLoopState);
hitSurface.addEventListener("pointerdown", onPointerDown);
Expand All @@ -836,6 +853,7 @@ export function InteractiveReceipt({
observer.disconnect();
intersectionObserver.disconnect();
desktopMedia.removeEventListener("change", syncLoopState);
reducedMotionMedia.removeEventListener("change", syncLoopState);
document.removeEventListener("visibilitychange", syncLoopState);
window.removeEventListener("resize", syncLoopState);
hitSurface.removeEventListener("pointerdown", onPointerDown);
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/lib/interactive-receipt-frame-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import {
RECEIPT_FRAME_INTERVAL_MS,
isReceiptSettled,
nextReceiptIdleFrame,
shouldSimulateReceiptFrame,
} from "./interactive-receipt-frame-policy";

describe("interactive receipt frame policy", () => {
it("caps simulation work at 60 frames per second", () => {
expect(shouldSimulateReceiptFrame(8, 1)).toBe(false);
expect(shouldSimulateReceiptFrame(RECEIPT_FRAME_INTERVAL_MS + 1, 1)).toBe(true);
});

it("resets idle settling while dragging", () => {
expect(nextReceiptIdleFrame(true, 20)).toBe(0);
expect(nextReceiptIdleFrame(false, 20)).toBe(21);
});

it("stops only after layout and motion have settled", () => {
expect(isReceiptSettled(1, 36)).toBe(false);
expect(isReceiptSettled(2, 35)).toBe(false);
expect(isReceiptSettled(2, 36)).toBe(true);
});
});
14 changes: 14 additions & 0 deletions apps/web/src/lib/interactive-receipt-frame-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const RECEIPT_FRAME_INTERVAL_MS = 1000 / 60;
export const RECEIPT_IDLE_SETTLE_FRAMES = 36;

export function shouldSimulateReceiptFrame(time: number, lastFrameTime: number): boolean {
return lastFrameTime === 0 || time - lastFrameTime >= RECEIPT_FRAME_INTERVAL_MS - 1;
}

export function nextReceiptIdleFrame(isDragging: boolean, idleFrames: number): number {
return isDragging ? 0 : idleFrames + 1;
}

export function isReceiptSettled(stableFrames: number, idleFrames: number): boolean {
return stableFrames >= 2 && idleFrames >= RECEIPT_IDLE_SETTLE_FRAMES;
}
13 changes: 13 additions & 0 deletions tests/e2e/browsing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
"Core browsing smoke session",
);

expect(consoleErrors).toEqual([]);

Check failure on line 95 in tests/e2e/browsing.spec.ts

View workflow job for this annotation

GitHub Actions / smoke

[web-chromium] › tests/e2e/browsing.spec.ts:3:5 › covers dashboard

1) [web-chromium] › tests/e2e/browsing.spec.ts:3:5 › covers dashboard, detail, search, projects, and pin flows Error: expect(received).toEqual(expected) // deep equality - Expected - 1 + Received + 3 - Array [] + Array [ + "Failed to refresh dashboard: TypeError: Failed to fetch", + ] 93 | ); 94 | > 95 | expect(consoleErrors).toEqual([]); | ^ 96 | }); 97 | 98 | test("keeps detail drawers modal and restores focus", async ({ page }) => { at /home/runner/work/codesesh/codesesh/tests/e2e/browsing.spec.ts:95:25
});

test("keeps detail drawers modal and restores focus", async ({ page }) => {
Expand Down Expand Up @@ -131,3 +131,16 @@
await page.setViewportSize({ width: 1280, height: 800 });
await expect(tocDialog).toBeHidden();
});

test("renders a static receipt for reduced motion", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/claudecode/e2e-dashboard");
await page.getByRole("button", { name: "Open session receipt" }).click();

const receiptCanvas = page.locator('canvas[aria-hidden="true"].fixed');
const receiptHitSurface = page.locator(
'[aria-label="Interactive thermal receipt with Verlet paper simulation"]',
);
await expect(receiptCanvas).toBeVisible();
await expect(receiptHitSurface).toBeHidden();
});
Loading