diff --git a/components/settings/__tests__/spam-siege-game.test.tsx b/components/settings/__tests__/spam-siege-game.test.tsx index cd15700f4..0aea4775c 100644 --- a/components/settings/__tests__/spam-siege-game.test.tsx +++ b/components/settings/__tests__/spam-siege-game.test.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { act } from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { SpamSiegeGame } from '../spam-siege-game'; +import { SpamSiegeGame, mayFlash, FLASH_MIN_GAP_MS } from '../spam-siege-game'; // Echo interpolation values so the shield count is visible in the label. vi.mock('next-intl', () => ({ @@ -145,3 +145,20 @@ describe('SpamSiegeGame', () => { expect(onClose).toHaveBeenCalledTimes(1); }); }); + +// WCAG 2.3.1: nothing flashes more than three times a second, however fast +// mail is caught or missed. +describe('inbox flash rate', () => { + it('lets the inbox flash at most three times a second', () => { + expect(FLASH_MIN_GAP_MS).toBeGreaterThanOrEqual(1000 / 3); + expect(mayFlash(-Infinity, 0)).toBe(true); + expect(mayFlash(1000, 1200)).toBe(false); + expect(mayFlash(1000, 1000 + FLASH_MIN_GAP_MS)).toBe(true); + let flashes = 0; + let last = -Infinity; + for (let t = 0; t < 1000; t += 50) { + if (mayFlash(last, t)) { flashes++; last = t; } + } + expect(flashes).toBeLessThanOrEqual(3); + }); +}); diff --git a/components/settings/spam-siege-game.tsx b/components/settings/spam-siege-game.tsx index 9eb20d138..b3e6bfe2f 100644 --- a/components/settings/spam-siege-game.tsx +++ b/components/settings/spam-siege-game.tsx @@ -36,6 +36,14 @@ const MAX_STEP_MS = 50; const EXIT_MS = 400; const POP_MS = 700; const FLASH_MS = 280; +// The inbox tints at most three times a second, however fast the catches +// and misses come (WCAG 2.3.1, three flashes). +export const FLASH_MIN_GAP_MS = 334; + +/** Whether the inbox may flash again, `now` ms after the last flash began at `last`. */ +export function mayFlash(last: number, now: number): boolean { + return now - last >= FLASH_MIN_GAP_MS; +} const COMBO_STEP = 5; const COMBO_MAX = 3; const BLOCKED_PENALTY = 15; @@ -149,6 +157,7 @@ function freshGame() { nextId: 0, nextPopId: 0, flashToken: 0, + lastFlashAt: -Infinity, elapsed: 0, spawnIn: 250, last: 0, @@ -248,6 +257,9 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) { const flashInbox = useCallback( (kind: "good" | "bad") => { + const now = performance.now(); + if (!mayFlash(game.current.lastFlashAt, now)) return; + game.current.lastFlashAt = now; const token = ++game.current.flashToken; setFlash(kind); later(() => {