From 1714b585767f4f60f71e69901ca671c6581df48d Mon Sep 17 00:00:00 2001 From: Chris <151883835+VeronicDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:54:58 +0000 Subject: [PATCH 1/2] fix: StreamRow reduced-motion JS hook + data-reduced-motion attribute + swipe-transition guard. CSS-level prefers-reduced-motion fallback in globals.css. Focused tests for all statuses. --- app/components/StreamRow.test.tsx | 114 ++++++++++++++++++++++++++++++ app/components/StreamRow.tsx | 28 +++++++- app/globals.css | 4 ++ 3 files changed, 144 insertions(+), 2 deletions(-) diff --git a/app/components/StreamRow.test.tsx b/app/components/StreamRow.test.tsx index b69c13d..696839e 100644 --- a/app/components/StreamRow.test.tsx +++ b/app/components/StreamRow.test.tsx @@ -226,6 +226,120 @@ describe("StreamRow", () => { }); }); + // ── matchMedia mock ───────────────────────────────────────────────────────── + + /** Installs a matchMedia mock that reports the given reduced-motion preference. */ + function mockMatchMedia(prefersReduced: boolean) { + window.matchMedia = jest.fn().mockImplementation((query: string) => ({ + matches: query.includes("prefers-reduced-motion") ? prefersReduced : false, + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + } + + describe("reduced-motion fallback (Issue #1038)", () => { + afterEach(() => { + // @ts-expect-error reset between tests + delete window.matchMedia; + }); + + it("sets data-reduced-motion=false on the article element by default", () => { + mockMatchMedia(false); + const { container } = render(); + const article = container.querySelector("article.stream-row"); + expect(article).toHaveAttribute("data-reduced-motion", "false"); + }); + + it("sets data-reduced-motion=true when prefers-reduced-motion is active", () => { + mockMatchMedia(true); + const { container } = render(); + const article = container.querySelector("article.stream-row"); + expect(article).toHaveAttribute("data-reduced-motion", "true"); + }); + + it("sets data-reduced-motion on the cancel-reveal element", () => { + mockMatchMedia(true); + const cancellableStream: StreamRowData = { + ...makeMockStream("active"), + nextAction: "Cancel", + }; + const { container } = render(); + const reveal = container.querySelector(".stream-row__cancel-reveal"); + expect(reveal).toHaveAttribute("data-reduced-motion", "true"); + }); + + it("sets data-reduced-motion on the cancel-reveal to false by default", () => { + mockMatchMedia(false); + const cancellableStream: StreamRowData = { + ...makeMockStream("active"), + nextAction: "Cancel", + }; + const { container } = render(); + const reveal = container.querySelector(".stream-row__cancel-reveal"); + expect(reveal).toHaveAttribute("data-reduced-motion", "false"); + }); + + it("applies transition: none to cancel-label when reduced motion is requested", () => { + mockMatchMedia(true); + const cancellableStream: StreamRowData = { + ...makeMockStream("active"), + nextAction: "Cancel", + }; + const { container } = render(); + const label = container.querySelector(".stream-row__cancel-label") as HTMLElement; + expect(label.style.transition).toBe("none"); + }); + + it("does not force transition: none on cancel-label when reduced motion is not requested", () => { + mockMatchMedia(false); + const cancellableStream: StreamRowData = { + ...makeMockStream("active"), + nextAction: "Cancel", + }; + const { container } = render(); + const label = container.querySelector(".stream-row__cancel-label") as HTMLElement; + expect(label.style.transition).toBe(""); + }); + + it("applies transition: none to swipe style when reduced motion is requested", () => { + mockMatchMedia(true); + const cancellableStream: StreamRowData = { + ...makeMockStream("active"), + nextAction: "Cancel", + }; + const { container } = render(); + const article = container.querySelector("article.stream-row") as HTMLElement; + + // Simulate a left swipe + fireEvent.touchStart(article, { touches: [{ clientX: 200, clientY: 100 }] }); + fireEvent.touchMove(article, { touches: [{ clientX: 50, clientY: 100 }] }); + + expect(article.style.transition).toBe("none"); + }); + + it("preserves data-status attribute regardless of motion preference", () => { + mockMatchMedia(true); + const { container } = render(); + const article = container.querySelector("article.stream-row"); + expect(article).toHaveAttribute("data-status", baseStream.status); + }); + + it.each(ALL_STATUSES)( + "sets data-reduced-motion on article for status=%s", + (status) => { + mockMatchMedia(true); + const { container } = render(); + const article = container.querySelector("article.stream-row"); + expect(article).toHaveAttribute("data-reduced-motion", "true"); + }, + ); + }); + describe("swipe to cancel (mobile)", () => { const cancellableStream: StreamRowData = { ...makeMockStream("active"), diff --git a/app/components/StreamRow.tsx b/app/components/StreamRow.tsx index 17ac8ee..a07439f 100644 --- a/app/components/StreamRow.tsx +++ b/app/components/StreamRow.tsx @@ -14,6 +14,7 @@ import type { StreamPayError } from "../lib/errors/types"; import { LiveRegion } from "../../src/components/LiveRegion"; import { KbdHint } from "../../src/components/KbdHint"; import { colorFromId } from "../utils/colorFromId"; +import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion"; const SWIPE_CANCEL_THRESHOLD = 80; const SWIPE_CANCEL_MAX = 160; @@ -42,7 +43,18 @@ type StreamRowProps = { density?: "cozy" | "compact"; }; +/** + * StreamRow renders a single payment stream card with status, progress, + * recipient info, action controls, swipe-to-cancel, and a color-blind-safe + * pattern overlay. + * + * Data attributes exposed for e2e / CSS hooks: + * - `data-status` — stream lifecycle status (active, draft, paused, etc.) + * - `data-reduced-motion` — "true" when the user prefers reduced motion + * (Issue #1038); used to gate swipe transitions and animation fallbacks. + export function StreamRow({ stream, density = "cozy" }: StreamRowProps) { + const prefersReducedMotion = usePrefersReducedMotion(); const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); const [isIncidentMode] = useState(false); @@ -169,7 +181,10 @@ export function StreamRow({ stream, density = "cozy" }: StreamRowProps) { const swipeStyle = canSwipeCancel && swipeOffset !== 0 - ? { transform: `translateX(${swipeOffset}px)` } + ? { + transform: `translateX(${swipeOffset}px)`, + transition: prefersReducedMotion ? "none" : undefined, + } : undefined; return ( @@ -183,6 +198,7 @@ export function StreamRow({ stream, density = "cozy" }: StreamRowProps) { .filter(Boolean) .join(" ")} data-status={stream.status} + data-reduced-motion={prefersReducedMotion ? "true" : "false"} aria-labelledby={`${stream.id}-recipient`} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} @@ -194,8 +210,16 @@ export function StreamRow({ stream, density = "cozy" }: StreamRowProps) { className="stream-row__cancel-reveal" aria-hidden="true" data-swipe-active={swipeOffset < -SWIPE_CANCEL_THRESHOLD} + data-reduced-motion={prefersReducedMotion ? "true" : "false"} > - Cancel + + Cancel + )} diff --git a/app/globals.css b/app/globals.css index 5f0096a..b2f25a2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1012,6 +1012,10 @@ a:hover { } @media (prefers-reduced-motion: reduce) { + .stream-row { + transition: none !important; + } + .stream-row--swiping { transition: none !important; } From a5dec1dbafce29404be23dd226ed03cd9eccea11 Mon Sep 17 00:00:00 2001 From: Chris <151883835+VeronicDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:08:32 +0000 Subject: [PATCH 2/2] feat: StreamRow skeleton first paint (Issue #1033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a loading prop to StreamRow that renders a themed skeleton placeholder matching the StreamRow layout — identity avatar, recipient name, schedule, status badge, meta section (Rate/Status/Burn-down), progress bar, and action button. Uses the existing Skeleton component with proper aria-busy and aria-hidden for accessibility. --- app/components/StreamRow.test.tsx | 86 +++++++++++++++++++++++++++++++ app/components/StreamRow.tsx | 86 ++++++++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/app/components/StreamRow.test.tsx b/app/components/StreamRow.test.tsx index 696839e..e295954 100644 --- a/app/components/StreamRow.test.tsx +++ b/app/components/StreamRow.test.tsx @@ -340,6 +340,92 @@ describe("StreamRow", () => { ); }); + describe("loading skeleton (Issue #1033)", () => { + afterEach(() => { + // @ts-expect-error reset between tests + delete window.matchMedia; + }); + + it("renders skeleton when loading is true", () => { + const { container } = render(); + const article = container.querySelector("article.stream-row"); + expect(article).toHaveClass("stream-row--skeleton"); + }); + + it("applies aria-busy on the article element when loading", () => { + const { container } = render(); + const article = container.querySelector("article.stream-row"); + expect(article).toHaveAttribute("aria-busy", "true"); + }); + + it("applies aria-label to indicate loading state", () => { + render(); + expect(screen.getByLabelText("Stream row is loading")).toBeInTheDocument(); + }); + + it("does not render live content (recipient, action button) when loading", () => { + const { container } = render(); + expect(container.querySelector("h2")).toBeNull(); + expect(container.querySelector("button")).toBeNull(); + expect(container.querySelector(".status-badge")).toBeNull(); + expect(container.querySelector(".stream-progress")).toBeNull(); + expect(container.querySelector(".stream-row__pattern")).toBeNull(); + }); + + it("renders Skeleton elements inside the row", () => { + const { container } = render(); + const skeletons = container.querySelectorAll(".skeleton"); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it("renders skeleton avatar (circle)", () => { + const { container } = render(); + const circles = container.querySelectorAll("[style*='border-radius: 50%']"); + expect(circles.length).toBeGreaterThan(0); + }); + + it("skeleton elements are aria-hidden from screen readers", () => { + const { container } = render(); + const skeletons = container.querySelectorAll(".skeleton"); + skeletons.forEach((sk) => { + expect(sk).toHaveAttribute("aria-hidden", "true"); + }); + }); + + it("applies stream-row--compact modifier when density=compact and loading", () => { + const { container } = render( + + ); + const article = container.querySelector("article.stream-row"); + expect(article).toHaveClass("stream-row--compact"); + expect(article).toHaveClass("stream-row--skeleton"); + }); + + it("renders skeleton for the color stripe placeholder", () => { + const { container } = render(); + const stripe = container.querySelector(".stream-row__color-stripe"); + expect(stripe).not.toBeNull(); + expect(stripe).toHaveAttribute("aria-hidden", "true"); + }); + + it("renders meta section with dt elements in skeleton", () => { + const { container } = render(); + const dts = container.querySelectorAll("dt"); + expect(dts.length).toBeGreaterThan(0); + }); + + it("does not render skeleton when loading is false (normal render)", () => { + const { container } = render(); + expect(container.querySelector(".stream-row--skeleton")).toBeNull(); + expect(container.querySelector("button")).not.toBeNull(); + }); + + it("does not render skeleton when loading is undefined (normal render)", () => { + const { container } = render(); + expect(container.querySelector(".stream-row--skeleton")).toBeNull(); + }); + }); + describe("swipe to cancel (mobile)", () => { const cancellableStream: StreamRowData = { ...makeMockStream("active"), diff --git a/app/components/StreamRow.tsx b/app/components/StreamRow.tsx index a07439f..c7c0865 100644 --- a/app/components/StreamRow.tsx +++ b/app/components/StreamRow.tsx @@ -15,6 +15,7 @@ import { LiveRegion } from "../../src/components/LiveRegion"; import { KbdHint } from "../../src/components/KbdHint"; import { colorFromId } from "../utils/colorFromId"; import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion"; +import { Skeleton } from "./Skeleton"; const SWIPE_CANCEL_THRESHOLD = 80; const SWIPE_CANCEL_MAX = 160; @@ -41,6 +42,14 @@ export type StreamRowData = { type StreamRowProps = { stream: StreamRowData; density?: "cozy" | "compact"; + /** + * When true, renders a themed skeleton placeholder matching the StreamRow + * layout — shimmer blocks for identity, meta, progress bar, and action + * button — while stream data is loading. + * The wrapper carries `aria-busy="true"` and skeleton children are + * `aria-hidden="true"` for screen readers. + */ + loading?: boolean; }; /** @@ -52,8 +61,83 @@ type StreamRowProps = { * - `data-status` — stream lifecycle status (active, draft, paused, etc.) * - `data-reduced-motion` — "true" when the user prefers reduced motion * (Issue #1038); used to gate swipe transitions and animation fallbacks. + */ + +export function StreamRow({ stream, density = "cozy", loading = false }: StreamRowProps) { + // ── Loading skeleton (early return before any hooks) ─────────────────────── + if (loading) { + const compact = density === "compact"; + return ( +
+ {/* Color stripe placeholder */} + + + {/* Primary section — identity + badge */} +
+
+ {/* Recipient avatar skeleton */} +
+ {/* Status badge skeleton */} + +
+ + {/* Meta section — Rate + Status + Burn-down */} + + + {/* Stream progress skeleton */} + + + {/* Action button skeleton */} +
+ +
+
+ ); + } -export function StreamRow({ stream, density = "cozy" }: StreamRowProps) { const prefersReducedMotion = usePrefersReducedMotion(); const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null);