setInteracting(true)}
+ onMouseLeave={() => setInteracting(false)}
+ onFocus={() => setInteracting(true)}
+ onBlur={() => setInteracting(false)}
+ >
{slides.map((slide, i) => {
const rel = (((i - active) % n) + n) % n;
@@ -114,19 +140,35 @@ function HeroCarousel({ slides }: { slides: HeroSlide[] }) {
{n > 1 && (
-
- {slides.map((_, i) => (
-
diff --git a/packages/web/lib/components/home/__tests__/HeroDecodeBand.pause.test.tsx b/packages/web/lib/components/home/__tests__/HeroDecodeBand.pause.test.tsx
new file mode 100644
index 00000000..6ca30fe4
--- /dev/null
+++ b/packages/web/lib/components/home/__tests__/HeroDecodeBand.pause.test.tsx
@@ -0,0 +1,175 @@
+/**
+ * @vitest-environment jsdom
+ */
+import React, { act } from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { NextIntlClientProvider } from "next-intl";
+import en from "@/messages/en.json";
+
+// Sever the design-system barrel (it pulls the app-wide store/API graph);
+// HeroDecodeBand only needs the ScanBracketGlyph icon.
+vi.mock("@/lib/design-system", () => ({
+ ScanBracketGlyph: () =>
,
+}));
+vi.mock("next/image", () => ({
+ default: (props: React.ImgHTMLAttributes
) => (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ),
+}));
+
+import { HeroDecodeBand } from "../HeroDecodeBand";
+import type { HeroSlide } from "../types";
+
+function makeSlides(n: number): HeroSlide[] {
+ return Array.from({ length: n }, (_, i) => ({
+ imageUrl: `https://example.com/${i}.jpg`,
+ styleRead: null,
+ }));
+}
+
+function renderHero(slides: HeroSlide[]) {
+ return render(
+
+
+
+ );
+}
+
+/** Controllable `window.matchMedia` mock for `(prefers-reduced-motion: reduce)`. */
+function mockMatchMedia(initialMatches: boolean) {
+ let matches = initialMatches;
+ const listeners = new Set<(e: MediaQueryListEvent) => void>();
+ const mql = {
+ get matches() {
+ return matches;
+ },
+ media: "(prefers-reduced-motion: reduce)",
+ addEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
+ listeners.add(cb);
+ },
+ removeEventListener: (
+ _type: string,
+ cb: (e: MediaQueryListEvent) => void
+ ) => {
+ listeners.delete(cb);
+ },
+ dispatchEvent: () => true,
+ };
+ window.matchMedia = vi.fn().mockReturnValue(mql);
+ return {
+ setMatches(next: boolean) {
+ matches = next;
+ listeners.forEach((cb) => cb({ matches: next } as MediaQueryListEvent));
+ },
+ };
+}
+
+/** Active look = the dot button carrying the "current" width class. */
+function activeDotIndex(): number {
+ const dots = screen.getAllByRole("button", { name: /^Show look \d+$/ });
+ return dots.findIndex((d) => d.className.includes("w-5"));
+}
+
+describe("HeroDecodeBand carousel auto-rotate pause/control (#848)", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it("auto-rotates every 5s by default when motion is not reduced", () => {
+ mockMatchMedia(false);
+ renderHero(makeSlides(3));
+
+ expect(activeDotIndex()).toBe(0);
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(activeDotIndex()).toBe(1);
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(activeDotIndex()).toBe(2);
+ });
+
+ it("shows a visible pause toggle and stops rotation once paused", () => {
+ mockMatchMedia(false);
+ renderHero(makeSlides(3));
+
+ const pauseButton = screen.getByRole("button", { name: "Pause carousel" });
+ // APG rotation-control pattern: accessible-name swap alone communicates
+ // state; no aria-pressed (avoids "Play carousel, pressed" contradictions).
+ expect(pauseButton).not.toHaveAttribute("aria-pressed");
+
+ fireEvent.click(pauseButton);
+
+ const playButton = screen.getByRole("button", { name: "Play carousel" });
+ expect(playButton).not.toHaveAttribute("aria-pressed");
+
+ act(() => {
+ vi.advanceTimersByTime(15000);
+ });
+ expect(activeDotIndex()).toBe(0);
+ });
+
+ it("resumes rotation from the current slide when play is pressed again", () => {
+ mockMatchMedia(false);
+ renderHero(makeSlides(3));
+
+ fireEvent.click(screen.getByRole("button", { name: "Pause carousel" }));
+ fireEvent.click(screen.getByRole("button", { name: "Play carousel" }));
+
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(activeDotIndex()).toBe(1);
+ });
+
+ it("does not auto-start rotation when prefers-reduced-motion is set", () => {
+ mockMatchMedia(true);
+ renderHero(makeSlides(3));
+
+ // Starts paused: the toggle already reads "Play carousel".
+ expect(
+ screen.getByRole("button", { name: "Play carousel" })
+ ).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(15000);
+ });
+ expect(activeDotIndex()).toBe(0);
+ });
+
+ it("pauses rotation on focus-within and resumes after blur", () => {
+ mockMatchMedia(false);
+ renderHero(makeSlides(3));
+
+ const pauseButton = screen.getByRole("button", { name: "Pause carousel" });
+ fireEvent.focus(pauseButton);
+
+ act(() => {
+ vi.advanceTimersByTime(15000);
+ });
+ expect(activeDotIndex()).toBe(0);
+
+ fireEvent.blur(pauseButton);
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(activeDotIndex()).toBe(1);
+ });
+
+ it("does not render a pause toggle for a single slide (nothing to control)", () => {
+ mockMatchMedia(false);
+ renderHero(makeSlides(1));
+
+ expect(
+ screen.queryByRole("button", { name: "Pause carousel" })
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/web/messages/en.json b/packages/web/messages/en.json
index 9285dcd1..66bfb0a1 100644
--- a/packages/web/messages/en.json
+++ b/packages/web/messages/en.json
@@ -87,7 +87,9 @@
"decodeCta": "Decode a look",
"pasteUrlCta": "Paste URL",
"slideAlt": "Decoded look",
- "showLookAria": "Show look {index}"
+ "showLookAria": "Show look {index}",
+ "pauseCarouselAria": "Pause carousel",
+ "playCarouselAria": "Play carousel"
},
"quickDecode": {
"aria": "Quick decode",
diff --git a/packages/web/messages/ko.json b/packages/web/messages/ko.json
index 5837ae76..bf641218 100644
--- a/packages/web/messages/ko.json
+++ b/packages/web/messages/ko.json
@@ -87,7 +87,9 @@
"decodeCta": "룩 디코드하기",
"pasteUrlCta": "URL 붙여넣기",
"slideAlt": "디코드된 룩",
- "showLookAria": "{index}번 룩 보기"
+ "showLookAria": "{index}번 룩 보기",
+ "pauseCarouselAria": "캐러셀 일시정지",
+ "playCarouselAria": "캐러셀 재생"
},
"quickDecode": {
"aria": "빠른 디코드",