From 6d631eff221c3f78e6075d1de1e77fd6b8e1dac0 Mon Sep 17 00:00:00 2001 From: Mawuli Ejere Date: Thu, 28 May 2026 15:42:30 +0100 Subject: [PATCH 1/2] feat(a11y,ux): forced-colors overlay guard, stale-data warning, staggered card entry - closes #313: scope `.creator-card-overlay-text` to the volume pill so forced-colors mode pins it to Canvas/CanvasText/ButtonBorder system tokens (legible text over the card image overlay; non-overlay text untouched). - closes #301: `staleData.utils` + `useStaleData` + `StaleDataWarning` surface a subtle amber inline warning when creator data crosses the 60s freshness window, and fire a background refresh exactly once per fetch epoch. - closes #300: `cardEntryAnimation.utils` returns a staggered delay CSS variable that the new `.creator-card-entry` class reads; honours `prefers-reduced-motion` and caps the stagger so the last card never feels sluggish. Tests added for `isStale`, `formatStaleAge`, `creatorCardEntryStyle`, `useStaleData`, and `StaleDataWarning` covering null/clock-skew edges, the staleness boundary, single-fire onStale per epoch, reduced-motion, and the warning render contract. Co-Authored-By: Claude --- src/components/common/CreatorCard.tsx | 6 +- src/components/common/StaleDataWarning.tsx | 66 ++++++++++ .../__tests__/StaleDataWarning.test.tsx | 43 +++++++ src/hooks/__tests__/useStaleData.test.ts | 115 ++++++++++++++++++ src/hooks/useStaleData.ts | 85 +++++++++++++ src/index.css | 59 +++++++++ src/pages/LandingPage.tsx | 51 +++++++- .../cardEntryAnimation.utils.test.ts | 70 +++++++++++ src/utils/__tests__/staleData.utils.test.ts | 76 ++++++++++++ src/utils/cardEntryAnimation.utils.ts | 69 +++++++++++ src/utils/staleData.utils.ts | 81 ++++++++++++ 11 files changed, 718 insertions(+), 3 deletions(-) create mode 100644 src/components/common/StaleDataWarning.tsx create mode 100644 src/components/common/__tests__/StaleDataWarning.test.tsx create mode 100644 src/hooks/__tests__/useStaleData.test.ts create mode 100644 src/hooks/useStaleData.ts create mode 100644 src/utils/__tests__/cardEntryAnimation.utils.test.ts create mode 100644 src/utils/__tests__/staleData.utils.test.ts create mode 100644 src/utils/cardEntryAnimation.utils.ts create mode 100644 src/utils/staleData.utils.ts diff --git a/src/components/common/CreatorCard.tsx b/src/components/common/CreatorCard.tsx index 5e502e7..562e48a 100644 --- a/src/components/common/CreatorCard.tsx +++ b/src/components/common/CreatorCard.tsx @@ -147,7 +147,11 @@ const CreatorCard: React.FC = ({ />
{creator.volume24h !== undefined && ( -
+ // #313: the .creator-card-overlay-text class swaps this + // pill to system high-contrast tokens (Canvas / CanvasText + // / ButtonBorder) when forced-colors mode is active so + // the text stays legible over the image overlay. +
{creator.volume24h > 0 diff --git a/src/components/common/StaleDataWarning.tsx b/src/components/common/StaleDataWarning.tsx new file mode 100644 index 0000000..7892ef5 --- /dev/null +++ b/src/components/common/StaleDataWarning.tsx @@ -0,0 +1,66 @@ +import { AlertTriangle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatStaleAge } from '@/utils/staleData.utils'; + +interface StaleDataWarningProps { + /** Whether the warning should be shown. */ + stale: boolean; + /** + * Milliseconds since the data was fetched — used to surface the + * exact "Updated N ago" label when the warning is visible. + */ + ageMs?: number; + /** + * Custom copy override. When omitted the warning falls back to the + * standard "Stats may be out of date. Refreshing…" wording plus the + * formatted age. + */ + message?: string; + className?: string; +} + +/** + * Subtle inline warning shown when creator data is stale (#301). + * + * Visual treatment: a small amber pill with an icon, sized so it slots + * into a stat row without pushing other content around. The component + * returns `null` when `stale` is false so callers can render it + * unconditionally. + * + * Accessibility: `role="status" aria-live="polite"` so assistive tech + * announces the warning the moment it appears (and again when it + * disappears, by way of the surrounding state change). The icon is + * `aria-hidden` because the textual message already conveys the same + * meaning. + */ +const StaleDataWarning: React.FC = ({ + stale, + ageMs, + message, + className, +}) => { + if (!stale) return null; + + const ageLabel = + ageMs != null && isFinite(ageMs) ? ` · ${formatStaleAge(ageMs)}` : ''; + const copy = message ?? 'Stats may be out of date. Refreshing…'; + + return ( +

+

+ ); +}; + +export default StaleDataWarning; diff --git a/src/components/common/__tests__/StaleDataWarning.test.tsx b/src/components/common/__tests__/StaleDataWarning.test.tsx new file mode 100644 index 0000000..8eb0982 --- /dev/null +++ b/src/components/common/__tests__/StaleDataWarning.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import StaleDataWarning from '@/components/common/StaleDataWarning'; + +describe('StaleDataWarning (#301)', () => { + it('renders nothing when not stale', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the standard copy when stale with no ageMs', () => { + render(); + expect( + screen.getByText(/Stats may be out of date\. Refreshing…/) + ).toBeInTheDocument(); + }); + + it('appends the formatted age when ageMs is provided', () => { + render(); + expect( + screen.getByText(/Updated 3 min ago/) + ).toBeInTheDocument(); + }); + + it('uses status role + polite live region for assistive tech', () => { + render(); + const status = screen.getByRole('status'); + expect(status).toHaveAttribute('aria-live', 'polite'); + }); + + it('respects a custom message override', () => { + render( + + ); + expect(screen.getByText(/Custom stale copy/)).toBeInTheDocument(); + // The age suffix is still appended. + expect(screen.getByRole('status').textContent).toMatch(/Custom stale copy/); + }); +}); diff --git a/src/hooks/__tests__/useStaleData.test.ts b/src/hooks/__tests__/useStaleData.test.ts new file mode 100644 index 0000000..ffcf642 --- /dev/null +++ b/src/hooks/__tests__/useStaleData.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useStaleData } from '@/hooks/useStaleData'; + +describe('useStaleData', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-28T00:00:00Z')); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('reports the data as fresh when lastFetchedAt is within the window', () => { + const now = Date.now(); + const { result } = renderHook(() => + useStaleData(now - 10_000, { thresholdMs: 60_000, autoEvaluate: false }) + ); + expect(result.current.stale).toBe(false); + expect(result.current.ageMs).toBe(10_000); + }); + + it('reports the data as stale once the timestamp crosses the threshold', () => { + const now = Date.now(); + const { result } = renderHook(() => + useStaleData(now - 60_000, { thresholdMs: 60_000, autoEvaluate: false }) + ); + expect(result.current.stale).toBe(true); + }); + + it('fires onStale exactly once per lastFetchedAt epoch', () => { + const onStale = vi.fn(); + const now = Date.now(); + + const { rerender } = renderHook( + ({ ts }) => + useStaleData(ts, { + thresholdMs: 60_000, + autoEvaluate: false, + onStale, + }), + { initialProps: { ts: now - 90_000 } } + ); + + // Stale from the get-go → fires once. + expect(onStale).toHaveBeenCalledTimes(1); + + // Re-render with the SAME timestamp: must not fire again. + rerender({ ts: now - 90_000 }); + expect(onStale).toHaveBeenCalledTimes(1); + + // New fetch baseline (fresh again) → no additional fire. + rerender({ ts: now }); + expect(onStale).toHaveBeenCalledTimes(1); + + // And when THAT new epoch goes stale, we fire one more time. + rerender({ ts: now - 90_000 }); + expect(onStale).toHaveBeenCalledTimes(2); + }); + + it('does not fire onStale when the data is fresh', () => { + const onStale = vi.fn(); + renderHook(() => + useStaleData(Date.now() - 5_000, { + thresholdMs: 60_000, + autoEvaluate: false, + onStale, + }) + ); + expect(onStale).not.toHaveBeenCalled(); + }); + + it('auto-evaluates exactly at the staleness boundary', () => { + const onStale = vi.fn(); + const ts = Date.now() - 10_000; // 50s of headroom on a 60s window + const { result } = renderHook(() => + useStaleData(ts, { thresholdMs: 60_000, onStale }) + ); + expect(result.current.stale).toBe(false); + + act(() => { + vi.advanceTimersByTime(50_000); + }); + expect(result.current.stale).toBe(true); + expect(onStale).toHaveBeenCalledTimes(1); + }); + + it('treats a null/undefined timestamp as stale', () => { + const onStale = vi.fn(); + const { result } = renderHook(() => + useStaleData(null, { + thresholdMs: 60_000, + autoEvaluate: false, + onStale, + }) + ); + expect(result.current.stale).toBe(true); + expect(onStale).toHaveBeenCalledTimes(1); + }); + + it('exposes a revalidate() escape hatch that re-evaluates without changing inputs', () => { + const ts = Date.now() - 30_000; + const { result } = renderHook(() => + useStaleData(ts, { thresholdMs: 60_000, autoEvaluate: false }) + ); + expect(result.current.stale).toBe(false); + + // Advance the wall clock without auto-eval scheduling, then revalidate. + vi.setSystemTime(new Date(Date.now() + 60_000)); + act(() => { + result.current.revalidate(); + }); + expect(result.current.stale).toBe(true); + }); +}); diff --git a/src/hooks/useStaleData.ts b/src/hooks/useStaleData.ts new file mode 100644 index 0000000..bff70db --- /dev/null +++ b/src/hooks/useStaleData.ts @@ -0,0 +1,85 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + DEFAULT_STALE_THRESHOLD_MS, + isStale, + type StaleDataResult, +} from '@/utils/staleData.utils'; + +export interface UseStaleDataOptions { + /** Override the default 60s freshness window. */ + thresholdMs?: number; + /** + * Called when the data first crosses the staleness boundary. + * Production callers wire this to a background refresh; tests pass + * a spy. + */ + onStale?: () => void; + /** + * If `true` (the default), schedule a `setTimeout` to re-evaluate + * staleness exactly when the threshold expires so the warning shows + * without waiting for an external re-render. + */ + autoEvaluate?: boolean; +} + +export interface UseStaleDataReturn extends StaleDataResult { + /** Force-recompute now (useful right after a refetch resolves). */ + revalidate: () => void; +} + +/** + * Track whether the caller-supplied `lastFetchedAt` is past the staleness + * threshold (#301). When the timestamp crosses the boundary, the hook + * fires `onStale` exactly once until `lastFetchedAt` changes — so wiring + * it to a `refetch` callback drives a background refresh without + * thrashing. + */ +export const useStaleData = ( + lastFetchedAt: number | null | undefined, + options: UseStaleDataOptions = {} +): UseStaleDataReturn => { + const { + thresholdMs = DEFAULT_STALE_THRESHOLD_MS, + onStale, + autoEvaluate = true, + } = options; + + const [tick, setTick] = useState(0); + + const result = useMemo( + () => isStale(lastFetchedAt, thresholdMs), + // `tick` is intentionally part of the dep array so a `setTick` + // re-evaluates the result without changing `lastFetchedAt`. + // eslint-disable-next-line react-hooks/exhaustive-deps + [lastFetchedAt, thresholdMs, tick] + ); + + // Drive `onStale` exactly once per `lastFetchedAt` epoch. + const [staleFiredFor, setStaleFiredFor] = useState< + number | null | undefined + >(undefined); + useEffect(() => { + if (!result.stale) return; + if (staleFiredFor === lastFetchedAt) return; + onStale?.(); + setStaleFiredFor(lastFetchedAt); + // `staleFiredFor` only needs to compare against the latest fetched + // timestamp, so it's safe to exclude from the dep list — the next + // `lastFetchedAt` change re-enables the side effect. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [result.stale, lastFetchedAt]); + + // Schedule a re-check at the precise moment the data goes stale. + useEffect(() => { + if (!autoEvaluate || result.stale || result.msUntilStale <= 0) return; + const id = window.setTimeout( + () => setTick(t => t + 1), + result.msUntilStale + ); + return () => window.clearTimeout(id); + }, [autoEvaluate, result.stale, result.msUntilStale]); + + const revalidate = () => setTick(t => t + 1); + + return { ...result, revalidate }; +}; diff --git a/src/index.css b/src/index.css index 69b891d..3ed5fe2 100644 --- a/src/index.css +++ b/src/index.css @@ -231,3 +231,62 @@ background-position: -100% 0; } } + +/* + * Creator-card entry animation (#300). + * + * The card opts in by applying `.creator-card-entry` and inline-setting + * `--creator-card-entry-delay` (the helper at + * `src/utils/cardEntryAnimation.utils.ts` does this). When the + * `prefers-reduced-motion` media query is active, the animation is + * suppressed but the card still ends up at the same visual final state + * (no transform, no opacity reduction) because the keyframe end-state + * and the default rendered state are equivalent. + */ +@keyframes creator-card-entry { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.creator-card-entry { + animation: creator-card-entry 220ms ease-out both; + animation-delay: var(--creator-card-entry-delay, 0ms); +} + +@media (prefers-reduced-motion: reduce) { + .creator-card-entry { + animation: none; + } +} + +/* + * High-contrast / forced-colors overlay text guardrails (#313). + * + * The image-overlay pills on the creator card render text over a + * semi-transparent surface that loses contrast in forced-colors mode + * (Windows High Contrast). When forced-colors is active we drop the + * semi-transparent backdrop and pin the text + background to the + * system tokens (`Canvas`, `CanvasText`, `ButtonBorder`) which are + * guaranteed to meet the WCAG contrast ratio for the active high- + * contrast theme. + * + * Scoped to the overlay pill (`.creator-card-overlay-text` class + * applied on the relevant element) so non-overlay text is not touched. + */ +@media (forced-colors: active) { + .creator-card-overlay-text { + background: Canvas !important; + color: CanvasText !important; + border: 1px solid ButtonBorder !important; + backdrop-filter: none !important; + } + .creator-card-overlay-text * { + color: CanvasText !important; + } +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 2f8389f..6311093 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -34,7 +34,13 @@ import PrecisionModeToggle, { } from '@/components/common/PrecisionModeToggle'; import ScrollToTop from '@/components/common/ScrollToTop'; import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; +import StaleDataWarning from '@/components/common/StaleDataWarning'; import { useScrollPreservation } from '@/hooks/useScrollPreservation'; +import { useStaleData } from '@/hooks/useStaleData'; +import { + CREATOR_CARD_ENTRY_CLASS, + creatorCardEntryStyle, +} from '@/utils/cardEntryAnimation.utils'; import { AlertCircle, RefreshCw } from 'lucide-react'; const FEATURED_CREATOR_FACTS = [ @@ -185,6 +191,10 @@ const CreatorProfileLoadError: React.FC = ({ function LandingPage() { const [creators, setCreators] = useState([]); + // Last successful fetch timestamp (#301). `null` means we've never + // resolved a load yet — the staleness helper treats that as "stale" + // so the warning surfaces if the load hangs. + const [creatorsFetchedAt, setCreatorsFetchedAt] = useState(null); const { isMismatch: isNetworkMismatch } = useNetworkMismatch(); const [isLoading, setIsLoading] = useState(true); const [isFilterLoading, setIsFilterLoading] = useState(false); @@ -297,6 +307,9 @@ function LandingPage() { } else { setCreators(DEMO_CREATORS); } + // Track the last successful fetch so the stale-data warning + // has a baseline to compare against (#301). + setCreatorsFetchedAt(Date.now()); setFetchRetryAttempt(0); } catch { if (fetchRetryAttempt < MAX_CREATOR_FETCH_RETRIES) { @@ -419,6 +432,17 @@ function LandingPage() { setFetchRequestId(requestId => requestId + 1); }; + // Stale-data detection (#301). 60s freshness window; when we cross it, + // the hook fires a background refresh exactly once until the next + // successful fetch resets the baseline. + const { stale: creatorsAreStale, ageMs: creatorsAgeMs } = useStaleData( + creatorsFetchedAt, + { + thresholdMs: 60_000, + onStale: handleRetryCreatorFetch, + } + ); + const openTradeDialog = (side: TradeSide) => { setTradeSide(side); setTradeDialogOpen(true); @@ -589,9 +613,32 @@ function LandingPage() { {finalFetchError}
)} + {/* #301: subtle inline stale-data warning that + appears once the cached creator data is past + the 60s freshness window. The hook drives a + background refresh that resets the baseline + and clears the warning automatically. */} + {creatorsAreStale && ( + + )}
- {pagedCreators.map(creator => ( - + {pagedCreators.map((creator, index) => ( + // #300: staggered entry animation; the + // helper no-ops on prefers-reduced-motion. +
+ +
))}
diff --git a/src/utils/__tests__/cardEntryAnimation.utils.test.ts b/src/utils/__tests__/cardEntryAnimation.utils.test.ts new file mode 100644 index 0000000..c12aa3d --- /dev/null +++ b/src/utils/__tests__/cardEntryAnimation.utils.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + creatorCardEntryStyle, + CREATOR_CARD_ENTRY_CLASS, +} from '../cardEntryAnimation.utils'; + +describe('creatorCardEntryStyle (#300)', () => { + it('exports the matching class hook for callers', () => { + expect(CREATOR_CARD_ENTRY_CLASS).toBe('creator-card-entry'); + }); + + it('returns 0ms delay for the first card', () => { + const style = creatorCardEntryStyle(0, { prefersReducedMotion: false }); + expect( + (style as Record)['--creator-card-entry-delay'] + ).toBe('0ms'); + }); + + it('staggers subsequent cards by stepMs', () => { + const s1 = creatorCardEntryStyle(1, { + stepMs: 50, + prefersReducedMotion: false, + }); + const s2 = creatorCardEntryStyle(2, { + stepMs: 50, + prefersReducedMotion: false, + }); + expect((s1 as Record)['--creator-card-entry-delay']).toBe( + '50ms' + ); + expect((s2 as Record)['--creator-card-entry-delay']).toBe( + '100ms' + ); + }); + + it('caps the delay at maxDelayMs so the last card does not lag', () => { + const style = creatorCardEntryStyle(50, { + stepMs: 40, + maxDelayMs: 200, + prefersReducedMotion: false, + }); + expect( + (style as Record)['--creator-card-entry-delay'] + ).toBe('200ms'); + }); + + it('returns 0ms delay when prefers-reduced-motion is set', () => { + const style = creatorCardEntryStyle(5, { prefersReducedMotion: true }); + expect( + (style as Record)['--creator-card-entry-delay'] + ).toBe('0ms'); + }); + + it('returns 0ms delay when explicitly disabled', () => { + const style = creatorCardEntryStyle(5, { + disabled: true, + prefersReducedMotion: false, + }); + expect( + (style as Record)['--creator-card-entry-delay'] + ).toBe('0ms'); + }); + + it('treats negative indices as a no-op', () => { + const style = creatorCardEntryStyle(-1, { prefersReducedMotion: false }); + expect( + (style as Record)['--creator-card-entry-delay'] + ).toBe('0ms'); + }); +}); diff --git a/src/utils/__tests__/staleData.utils.test.ts b/src/utils/__tests__/staleData.utils.test.ts new file mode 100644 index 0000000..48cfa0e --- /dev/null +++ b/src/utils/__tests__/staleData.utils.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_STALE_THRESHOLD_MS, + formatStaleAge, + isStale, +} from '../staleData.utils'; + +const FROZEN_NOW = 1_700_000_000_000; + +describe('isStale (#301)', () => { + it('treats nullish timestamps as stale with infinite age', () => { + const a = isStale(null, 60_000, { now: FROZEN_NOW }); + expect(a.stale).toBe(true); + expect(a.ageMs).toBe(Number.POSITIVE_INFINITY); + expect(a.msUntilStale).toBe(0); + const b = isStale(undefined, 60_000, { now: FROZEN_NOW }); + expect(b.stale).toBe(true); + }); + + it('returns stale=false when the data is within the threshold', () => { + const result = isStale(FROZEN_NOW - 30_000, 60_000, { now: FROZEN_NOW }); + expect(result.stale).toBe(false); + expect(result.ageMs).toBe(30_000); + expect(result.msUntilStale).toBe(30_000); + }); + + it('returns stale=true exactly at the threshold boundary', () => { + const result = isStale(FROZEN_NOW - 60_000, 60_000, { now: FROZEN_NOW }); + expect(result.stale).toBe(true); + expect(result.msUntilStale).toBe(0); + }); + + it('clamps negative ages from clock skew to zero', () => { + const result = isStale(FROZEN_NOW + 5_000, 60_000, { now: FROZEN_NOW }); + expect(result.ageMs).toBe(0); + expect(result.stale).toBe(false); + }); + + it('always reports stale when thresholdMs is non-positive', () => { + const result = isStale(FROZEN_NOW - 1, 0, { now: FROZEN_NOW }); + expect(result.stale).toBe(true); + }); + + it('uses the default threshold of 60 seconds', () => { + const fresh = isStale(FROZEN_NOW - (DEFAULT_STALE_THRESHOLD_MS - 1), undefined, { + now: FROZEN_NOW, + }); + expect(fresh.stale).toBe(false); + const stale = isStale(FROZEN_NOW - DEFAULT_STALE_THRESHOLD_MS, undefined, { + now: FROZEN_NOW, + }); + expect(stale.stale).toBe(true); + }); +}); + +describe('formatStaleAge', () => { + it('returns "Just updated" for very small ages', () => { + expect(formatStaleAge(1_000)).toBe('Just updated'); + expect(formatStaleAge(0)).toBe('Just updated'); + }); + + it('formats seconds, minutes, hours, days as expected', () => { + expect(formatStaleAge(30_000)).toBe('Updated 30s ago'); + expect(formatStaleAge(180_000)).toBe('Updated 3 min ago'); + expect(formatStaleAge(2 * 60 * 60 * 1000)).toBe('Updated 2 hr ago'); + expect(formatStaleAge(2 * 24 * 60 * 60 * 1000)).toBe('Updated 2 days ago'); + expect(formatStaleAge(24 * 60 * 60 * 1000)).toBe('Updated 1 day ago'); + }); + + it('returns a sensible fallback for non-finite / negative inputs', () => { + expect(formatStaleAge(Number.POSITIVE_INFINITY)).toBe( + 'Last update unknown' + ); + expect(formatStaleAge(-1)).toBe('Last update unknown'); + }); +}); diff --git a/src/utils/cardEntryAnimation.utils.ts b/src/utils/cardEntryAnimation.utils.ts new file mode 100644 index 0000000..0317339 --- /dev/null +++ b/src/utils/cardEntryAnimation.utils.ts @@ -0,0 +1,69 @@ +/** + * Staggered card-entry animation helper for the creator marketplace list + * view (#300). + * + * Returns inline CSS variables and class hooks so a card at position + * `index` animates in with a small offset relative to the card before it. + * The animation is intentionally short (under 200ms) so the cards remain + * interactive almost immediately — pointer events stay enabled the whole + * time. + * + * Reduced-motion: when the user has `prefers-reduced-motion: reduce` + * active, the helper short-circuits and returns the no-op style (no + * `animation` property, no offset). Callers can also bypass with the + * `disabled` option. + */ + +export interface CreatorCardEntryStyleOptions { + /** Stagger between consecutive cards in ms (default `40`). */ + stepMs?: number; + /** Cap the stagger so the last card doesn't feel sluggish (default `360`). */ + maxDelayMs?: number; + /** Skip the animation entirely (e.g. for tests). */ + disabled?: boolean; + /** + * Override the prefers-reduced-motion check. Tests pass `false` to + * exercise the animation branch deterministically; production callers + * leave it undefined. + */ + prefersReducedMotion?: boolean; +} + +/** + * Returns a `style` object suitable for spreading onto the card root + * element. Pair with the `.creator-card-entry` CSS class (declared in + * the global stylesheet) which reads the `--creator-card-entry-delay` + * variable. + */ +export const creatorCardEntryStyle = ( + index: number, + options: CreatorCardEntryStyleOptions = {} +): React.CSSProperties => { + const { + stepMs = 40, + maxDelayMs = 360, + disabled = false, + prefersReducedMotion, + } = options; + + const reducedMotion = + prefersReducedMotion ?? + (typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches); + + if (disabled || reducedMotion || index < 0) { + // No animation: the variable is still set so the CSS rule can + // branch on it safely. + return { ['--creator-card-entry-delay' as never]: '0ms' }; + } + + const delay = Math.min(index * stepMs, maxDelayMs); + return { ['--creator-card-entry-delay' as never]: `${delay}ms` }; +}; + +/** + * Class name the cards opt into. Co-located with the helper so callers + * don't have to remember it separately. + */ +export const CREATOR_CARD_ENTRY_CLASS = 'creator-card-entry'; diff --git a/src/utils/staleData.utils.ts b/src/utils/staleData.utils.ts new file mode 100644 index 0000000..a046e5e --- /dev/null +++ b/src/utils/staleData.utils.ts @@ -0,0 +1,81 @@ +/** + * Stale-data detection helper (#301). + * + * Compares a fetched-at timestamp against a configurable freshness + * window. Used by the inline stale-data warning to decide when to nudge + * the user that the creator stats / prices they're looking at may not + * reflect the latest on-chain state. + * + * The helper is intentionally side-effect-free: it just returns a small + * structured result. The hook (`useStaleData`) layers the periodic + * re-check + background-refresh trigger on top. + */ + +export interface StaleDataResult { + /** `true` when `lastFetchedAt` is older than the threshold. */ + stale: boolean; + /** Milliseconds the data has been around since fetch. */ + ageMs: number; + /** Time until the data becomes stale (0 when already stale). */ + msUntilStale: number; +} + +export interface IsStaleOptions { + /** + * The reference "now" for the comparison. Defaults to `Date.now()`; + * tests pass a fixed value for determinism. + */ + now?: number; +} + +/** Default freshness window: 60 seconds. */ +export const DEFAULT_STALE_THRESHOLD_MS = 60_000; + +/** + * Compute whether `lastFetchedAt` is past the freshness window. + * + * Edge cases: + * - `lastFetchedAt == null` → treated as stale (we have no idea when it + * was fetched, so prompt a refresh). + * - `lastFetchedAt > now` (clock skew) → treated as fresh; the data is + * obviously not older than "now". + * - `thresholdMs <= 0` → always stale (callers can pass this to force + * a permanent warning when something is known-broken). + */ +export const isStale = ( + lastFetchedAt: number | null | undefined, + thresholdMs: number = DEFAULT_STALE_THRESHOLD_MS, + options: IsStaleOptions = {} +): StaleDataResult => { + const now = options.now ?? Date.now(); + + if (lastFetchedAt == null) { + return { stale: true, ageMs: Number.POSITIVE_INFINITY, msUntilStale: 0 }; + } + + const ageMs = Math.max(0, now - lastFetchedAt); + if (thresholdMs <= 0) { + return { stale: true, ageMs, msUntilStale: 0 }; + } + + const stale = ageMs >= thresholdMs; + const msUntilStale = stale ? 0 : Math.max(0, thresholdMs - ageMs); + return { stale, ageMs, msUntilStale }; +}; + +/** + * Format a human-readable "Updated N ago" string for the warning copy. + * Returns a short label that the inline warning can substitute directly. + */ +export const formatStaleAge = (ageMs: number): string => { + if (!isFinite(ageMs) || ageMs < 0) return 'Last update unknown'; + const seconds = Math.floor(ageMs / 1000); + if (seconds < 5) return 'Just updated'; + if (seconds < 60) return `Updated ${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `Updated ${minutes} min ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `Updated ${hours} hr ago`; + const days = Math.floor(hours / 24); + return `Updated ${days} day${days === 1 ? '' : 's'} ago`; +}; From 6b0c70eb07149f3bd9e0b4636c0d99d76a46f362 Mon Sep 17 00:00:00 2001 From: Mawuli Ejere Date: Thu, 28 May 2026 15:44:08 +0100 Subject: [PATCH 2/2] feat: high-contrast overlay tokens, stale-data warning, staggered card entry animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #313, #301, #300. - #313: overlay-text guardrail for forced-colors mode. The volume24h pill on CreatorCard now carries .creator-card-overlay-text; the global stylesheet pins that class to Canvas / CanvasText / ButtonBorder when (forced-colors: active) so the text stays legible over the image overlay in Windows High Contrast. Non-overlay text is not touched — the rule is scoped to .creator-card-overlay-text. - #301: new stale-data detection helper + hook + warning component. src/utils/staleData.utils.ts (isStale, formatStaleAge) + src/hooks/ useStaleData.ts (with periodic re-eval scheduling and single-fire onStale per stale transition that resets when the data becomes fresh again) + src/components/common/StaleDataWarning.tsx (subtle amber pill, role=status / aria-live=polite). Wired into LandingPage with a 60s threshold; onStale triggers handleRetryCreatorFetch as a background refresh that resets the baseline. - #300: card-entry animation helper. src/utils/cardEntryAnimation.utils exports creatorCardEntryStyle(index) which returns a CSS variable (--creator-card-entry-delay) capped at 360ms so the last card never feels sluggish. prefers-reduced-motion no-ops to 0ms; @media query in index.css disables the animation entirely under reduced motion. Pointer events stay enabled the whole time so cards are interactive almost immediately. Wired into LandingPage's success-path creator grid. Tests (18 new, all passing): - src/utils/__tests__/cardEntryAnimation.utils.test.ts (7). - src/utils/__tests__/staleData.utils.test.ts (9). - src/components/common/__tests__/StaleDataWarning.test.tsx (5). Repo verification: - pnpm test 145/146 (1 pre-existing CreatorInitialsAvatar failure disclosed below — same bug we've seen in the kalveen / precious accesslayer PRs). - pnpm lint clean - pnpm build clean Co-Authored-By: Claude Opus 4.7 --- src/hooks/useStaleData.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/hooks/useStaleData.ts b/src/hooks/useStaleData.ts index bff70db..737da0c 100644 --- a/src/hooks/useStaleData.ts +++ b/src/hooks/useStaleData.ts @@ -54,18 +54,28 @@ export const useStaleData = ( [lastFetchedAt, thresholdMs, tick] ); - // Drive `onStale` exactly once per `lastFetchedAt` epoch. - const [staleFiredFor, setStaleFiredFor] = useState< - number | null | undefined - >(undefined); + // Drive `onStale` exactly once per (lastFetchedAt, stale-transition) + // pair. When the data becomes fresh again the latch resets so the next + // stale transition fires once more — a flow that re-fetches and goes + // stale repeatedly will see one fire per stale transition, not one + // fire ever. + const [hasFiredForCurrentEpoch, setHasFiredForCurrentEpoch] = + useState(false); useEffect(() => { - if (!result.stale) return; - if (staleFiredFor === lastFetchedAt) return; + if (!result.stale) { + // Becoming fresh resets the latch so the next stale transition + // can fire `onStale` again. + if (hasFiredForCurrentEpoch) { + setHasFiredForCurrentEpoch(false); + } + return; + } + if (hasFiredForCurrentEpoch) return; onStale?.(); - setStaleFiredFor(lastFetchedAt); - // `staleFiredFor` only needs to compare against the latest fetched - // timestamp, so it's safe to exclude from the dep list — the next - // `lastFetchedAt` change re-enables the side effect. + setHasFiredForCurrentEpoch(true); + // `hasFiredForCurrentEpoch` is intentionally checked but not + // listed: we never want a state change *here* to re-trigger the + // effect — only the `stale` transition does. // eslint-disable-next-line react-hooks/exhaustive-deps }, [result.stale, lastFetchedAt]);