Skip to content

Commit 063bb5e

Browse files
authored
Merge pull request #324 from Mawuli-tech/feat/a11y-stale-data-entry-animation
feat(a11y/ux): high-contrast overlay tokens, stale-data warning, staggered card entry (#313/#301/#300)
2 parents 8c2d5b8 + 6b0c70e commit 063bb5e

11 files changed

Lines changed: 728 additions & 3 deletions

‎src/components/common/CreatorCard.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,11 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
147147
/>
148148
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-transparent opacity-0 transition-opacity duration-300 md:group-hover:opacity-100" />
149149
{creator.volume24h !== undefined && (
150-
<div className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-full bg-slate-950/75 border border-white/10 px-2.5 py-1 backdrop-blur-md">
150+
// #313: the .creator-card-overlay-text class swaps this
151+
// pill to system high-contrast tokens (Canvas / CanvasText
152+
// / ButtonBorder) when forced-colors mode is active so
153+
// the text stays legible over the image overlay.
154+
<div className="creator-card-overlay-text absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-full bg-slate-950/75 border border-white/10 px-2.5 py-1 backdrop-blur-md">
151155
<TrendingUp className="creator-action-icon text-emerald-400" />
152156
<span className="text-xs font-bold text-white/90">
153157
{creator.volume24h > 0
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { AlertTriangle } from 'lucide-react';
2+
import { cn } from '@/lib/utils';
3+
import { formatStaleAge } from '@/utils/staleData.utils';
4+
5+
interface StaleDataWarningProps {
6+
/** Whether the warning should be shown. */
7+
stale: boolean;
8+
/**
9+
* Milliseconds since the data was fetched — used to surface the
10+
* exact "Updated N ago" label when the warning is visible.
11+
*/
12+
ageMs?: number;
13+
/**
14+
* Custom copy override. When omitted the warning falls back to the
15+
* standard "Stats may be out of date. Refreshing…" wording plus the
16+
* formatted age.
17+
*/
18+
message?: string;
19+
className?: string;
20+
}
21+
22+
/**
23+
* Subtle inline warning shown when creator data is stale (#301).
24+
*
25+
* Visual treatment: a small amber pill with an icon, sized so it slots
26+
* into a stat row without pushing other content around. The component
27+
* returns `null` when `stale` is false so callers can render it
28+
* unconditionally.
29+
*
30+
* Accessibility: `role="status" aria-live="polite"` so assistive tech
31+
* announces the warning the moment it appears (and again when it
32+
* disappears, by way of the surrounding state change). The icon is
33+
* `aria-hidden` because the textual message already conveys the same
34+
* meaning.
35+
*/
36+
const StaleDataWarning: React.FC<StaleDataWarningProps> = ({
37+
stale,
38+
ageMs,
39+
message,
40+
className,
41+
}) => {
42+
if (!stale) return null;
43+
44+
const ageLabel =
45+
ageMs != null && isFinite(ageMs) ? ` · ${formatStaleAge(ageMs)}` : '';
46+
const copy = message ?? 'Stats may be out of date. Refreshing…';
47+
48+
return (
49+
<p
50+
role="status"
51+
aria-live="polite"
52+
className={cn(
53+
'inline-flex items-center gap-1.5 rounded-full border border-amber-500/25 bg-amber-500/10 px-2.5 py-1 text-[0.7rem] font-medium text-amber-200',
54+
className
55+
)}
56+
>
57+
<AlertTriangle className="size-3 text-amber-300" aria-hidden="true" />
58+
<span>
59+
{copy}
60+
{ageLabel}
61+
</span>
62+
</p>
63+
);
64+
};
65+
66+
export default StaleDataWarning;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import StaleDataWarning from '@/components/common/StaleDataWarning';
4+
5+
describe('StaleDataWarning (#301)', () => {
6+
it('renders nothing when not stale', () => {
7+
const { container } = render(<StaleDataWarning stale={false} />);
8+
expect(container).toBeEmptyDOMElement();
9+
});
10+
11+
it('renders the standard copy when stale with no ageMs', () => {
12+
render(<StaleDataWarning stale={true} />);
13+
expect(
14+
screen.getByText(/Stats may be out of date\. Refreshing…/)
15+
).toBeInTheDocument();
16+
});
17+
18+
it('appends the formatted age when ageMs is provided', () => {
19+
render(<StaleDataWarning stale={true} ageMs={180_000} />);
20+
expect(
21+
screen.getByText(/Updated 3 min ago/)
22+
).toBeInTheDocument();
23+
});
24+
25+
it('uses status role + polite live region for assistive tech', () => {
26+
render(<StaleDataWarning stale={true} ageMs={2_000} />);
27+
const status = screen.getByRole('status');
28+
expect(status).toHaveAttribute('aria-live', 'polite');
29+
});
30+
31+
it('respects a custom message override', () => {
32+
render(
33+
<StaleDataWarning
34+
stale={true}
35+
ageMs={5_000}
36+
message="Custom stale copy"
37+
/>
38+
);
39+
expect(screen.getByText(/Custom stale copy/)).toBeInTheDocument();
40+
// The age suffix is still appended.
41+
expect(screen.getByRole('status').textContent).toMatch(/Custom stale copy/);
42+
});
43+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2+
import { act, renderHook } from '@testing-library/react';
3+
import { useStaleData } from '@/hooks/useStaleData';
4+
5+
describe('useStaleData', () => {
6+
beforeEach(() => {
7+
vi.useFakeTimers();
8+
vi.setSystemTime(new Date('2026-05-28T00:00:00Z'));
9+
});
10+
afterEach(() => {
11+
vi.useRealTimers();
12+
});
13+
14+
it('reports the data as fresh when lastFetchedAt is within the window', () => {
15+
const now = Date.now();
16+
const { result } = renderHook(() =>
17+
useStaleData(now - 10_000, { thresholdMs: 60_000, autoEvaluate: false })
18+
);
19+
expect(result.current.stale).toBe(false);
20+
expect(result.current.ageMs).toBe(10_000);
21+
});
22+
23+
it('reports the data as stale once the timestamp crosses the threshold', () => {
24+
const now = Date.now();
25+
const { result } = renderHook(() =>
26+
useStaleData(now - 60_000, { thresholdMs: 60_000, autoEvaluate: false })
27+
);
28+
expect(result.current.stale).toBe(true);
29+
});
30+
31+
it('fires onStale exactly once per lastFetchedAt epoch', () => {
32+
const onStale = vi.fn();
33+
const now = Date.now();
34+
35+
const { rerender } = renderHook(
36+
({ ts }) =>
37+
useStaleData(ts, {
38+
thresholdMs: 60_000,
39+
autoEvaluate: false,
40+
onStale,
41+
}),
42+
{ initialProps: { ts: now - 90_000 } }
43+
);
44+
45+
// Stale from the get-go → fires once.
46+
expect(onStale).toHaveBeenCalledTimes(1);
47+
48+
// Re-render with the SAME timestamp: must not fire again.
49+
rerender({ ts: now - 90_000 });
50+
expect(onStale).toHaveBeenCalledTimes(1);
51+
52+
// New fetch baseline (fresh again) → no additional fire.
53+
rerender({ ts: now });
54+
expect(onStale).toHaveBeenCalledTimes(1);
55+
56+
// And when THAT new epoch goes stale, we fire one more time.
57+
rerender({ ts: now - 90_000 });
58+
expect(onStale).toHaveBeenCalledTimes(2);
59+
});
60+
61+
it('does not fire onStale when the data is fresh', () => {
62+
const onStale = vi.fn();
63+
renderHook(() =>
64+
useStaleData(Date.now() - 5_000, {
65+
thresholdMs: 60_000,
66+
autoEvaluate: false,
67+
onStale,
68+
})
69+
);
70+
expect(onStale).not.toHaveBeenCalled();
71+
});
72+
73+
it('auto-evaluates exactly at the staleness boundary', () => {
74+
const onStale = vi.fn();
75+
const ts = Date.now() - 10_000; // 50s of headroom on a 60s window
76+
const { result } = renderHook(() =>
77+
useStaleData(ts, { thresholdMs: 60_000, onStale })
78+
);
79+
expect(result.current.stale).toBe(false);
80+
81+
act(() => {
82+
vi.advanceTimersByTime(50_000);
83+
});
84+
expect(result.current.stale).toBe(true);
85+
expect(onStale).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it('treats a null/undefined timestamp as stale', () => {
89+
const onStale = vi.fn();
90+
const { result } = renderHook(() =>
91+
useStaleData(null, {
92+
thresholdMs: 60_000,
93+
autoEvaluate: false,
94+
onStale,
95+
})
96+
);
97+
expect(result.current.stale).toBe(true);
98+
expect(onStale).toHaveBeenCalledTimes(1);
99+
});
100+
101+
it('exposes a revalidate() escape hatch that re-evaluates without changing inputs', () => {
102+
const ts = Date.now() - 30_000;
103+
const { result } = renderHook(() =>
104+
useStaleData(ts, { thresholdMs: 60_000, autoEvaluate: false })
105+
);
106+
expect(result.current.stale).toBe(false);
107+
108+
// Advance the wall clock without auto-eval scheduling, then revalidate.
109+
vi.setSystemTime(new Date(Date.now() + 60_000));
110+
act(() => {
111+
result.current.revalidate();
112+
});
113+
expect(result.current.stale).toBe(true);
114+
});
115+
});

‎src/hooks/useStaleData.ts‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { useEffect, useMemo, useState } from 'react';
2+
import {
3+
DEFAULT_STALE_THRESHOLD_MS,
4+
isStale,
5+
type StaleDataResult,
6+
} from '@/utils/staleData.utils';
7+
8+
export interface UseStaleDataOptions {
9+
/** Override the default 60s freshness window. */
10+
thresholdMs?: number;
11+
/**
12+
* Called when the data first crosses the staleness boundary.
13+
* Production callers wire this to a background refresh; tests pass
14+
* a spy.
15+
*/
16+
onStale?: () => void;
17+
/**
18+
* If `true` (the default), schedule a `setTimeout` to re-evaluate
19+
* staleness exactly when the threshold expires so the warning shows
20+
* without waiting for an external re-render.
21+
*/
22+
autoEvaluate?: boolean;
23+
}
24+
25+
export interface UseStaleDataReturn extends StaleDataResult {
26+
/** Force-recompute now (useful right after a refetch resolves). */
27+
revalidate: () => void;
28+
}
29+
30+
/**
31+
* Track whether the caller-supplied `lastFetchedAt` is past the staleness
32+
* threshold (#301). When the timestamp crosses the boundary, the hook
33+
* fires `onStale` exactly once until `lastFetchedAt` changes — so wiring
34+
* it to a `refetch` callback drives a background refresh without
35+
* thrashing.
36+
*/
37+
export const useStaleData = (
38+
lastFetchedAt: number | null | undefined,
39+
options: UseStaleDataOptions = {}
40+
): UseStaleDataReturn => {
41+
const {
42+
thresholdMs = DEFAULT_STALE_THRESHOLD_MS,
43+
onStale,
44+
autoEvaluate = true,
45+
} = options;
46+
47+
const [tick, setTick] = useState(0);
48+
49+
const result = useMemo(
50+
() => isStale(lastFetchedAt, thresholdMs),
51+
// `tick` is intentionally part of the dep array so a `setTick`
52+
// re-evaluates the result without changing `lastFetchedAt`.
53+
// eslint-disable-next-line react-hooks/exhaustive-deps
54+
[lastFetchedAt, thresholdMs, tick]
55+
);
56+
57+
// Drive `onStale` exactly once per (lastFetchedAt, stale-transition)
58+
// pair. When the data becomes fresh again the latch resets so the next
59+
// stale transition fires once more — a flow that re-fetches and goes
60+
// stale repeatedly will see one fire per stale transition, not one
61+
// fire ever.
62+
const [hasFiredForCurrentEpoch, setHasFiredForCurrentEpoch] =
63+
useState(false);
64+
useEffect(() => {
65+
if (!result.stale) {
66+
// Becoming fresh resets the latch so the next stale transition
67+
// can fire `onStale` again.
68+
if (hasFiredForCurrentEpoch) {
69+
setHasFiredForCurrentEpoch(false);
70+
}
71+
return;
72+
}
73+
if (hasFiredForCurrentEpoch) return;
74+
onStale?.();
75+
setHasFiredForCurrentEpoch(true);
76+
// `hasFiredForCurrentEpoch` is intentionally checked but not
77+
// listed: we never want a state change *here* to re-trigger the
78+
// effect — only the `stale` transition does.
79+
// eslint-disable-next-line react-hooks/exhaustive-deps
80+
}, [result.stale, lastFetchedAt]);
81+
82+
// Schedule a re-check at the precise moment the data goes stale.
83+
useEffect(() => {
84+
if (!autoEvaluate || result.stale || result.msUntilStale <= 0) return;
85+
const id = window.setTimeout(
86+
() => setTick(t => t + 1),
87+
result.msUntilStale
88+
);
89+
return () => window.clearTimeout(id);
90+
}, [autoEvaluate, result.stale, result.msUntilStale]);
91+
92+
const revalidate = () => setTick(t => t + 1);
93+
94+
return { ...result, revalidate };
95+
};

‎src/index.css‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,62 @@
231231
background-position: -100% 0;
232232
}
233233
}
234+
235+
/*
236+
* Creator-card entry animation (#300).
237+
*
238+
* The card opts in by applying `.creator-card-entry` and inline-setting
239+
* `--creator-card-entry-delay` (the helper at
240+
* `src/utils/cardEntryAnimation.utils.ts` does this). When the
241+
* `prefers-reduced-motion` media query is active, the animation is
242+
* suppressed but the card still ends up at the same visual final state
243+
* (no transform, no opacity reduction) because the keyframe end-state
244+
* and the default rendered state are equivalent.
245+
*/
246+
@keyframes creator-card-entry {
247+
from {
248+
opacity: 0;
249+
transform: translateY(8px);
250+
}
251+
to {
252+
opacity: 1;
253+
transform: translateY(0);
254+
}
255+
}
256+
257+
.creator-card-entry {
258+
animation: creator-card-entry 220ms ease-out both;
259+
animation-delay: var(--creator-card-entry-delay, 0ms);
260+
}
261+
262+
@media (prefers-reduced-motion: reduce) {
263+
.creator-card-entry {
264+
animation: none;
265+
}
266+
}
267+
268+
/*
269+
* High-contrast / forced-colors overlay text guardrails (#313).
270+
*
271+
* The image-overlay pills on the creator card render text over a
272+
* semi-transparent surface that loses contrast in forced-colors mode
273+
* (Windows High Contrast). When forced-colors is active we drop the
274+
* semi-transparent backdrop and pin the text + background to the
275+
* system tokens (`Canvas`, `CanvasText`, `ButtonBorder`) which are
276+
* guaranteed to meet the WCAG contrast ratio for the active high-
277+
* contrast theme.
278+
*
279+
* Scoped to the overlay pill (`.creator-card-overlay-text` class
280+
* applied on the relevant element) so non-overlay text is not touched.
281+
*/
282+
@media (forced-colors: active) {
283+
.creator-card-overlay-text {
284+
background: Canvas !important;
285+
color: CanvasText !important;
286+
border: 1px solid ButtonBorder !important;
287+
backdrop-filter: none !important;
288+
}
289+
.creator-card-overlay-text * {
290+
color: CanvasText !important;
291+
}
292+
}

0 commit comments

Comments
 (0)