fix: let the spam game's inbox flash at most three times a second

This commit is contained in:
Linus Rath
2026-10-29 19:38:46 +01:00
parent 597b546e11
commit 12676f247e
2 changed files with 30 additions and 1 deletions
@@ -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);
});
});
+12
View File
@@ -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(() => {