brackt/app/components/marketing/LandingPage.tsx
Chris Parsons 4bbcac1949
fix: resolve all 48 WCAG 2.2 AA accessibility issues (#439)
* fix: resolve all 48 WCAG 2.2 AA accessibility issues

Critical fixes:
- Add aria-label to all unlabeled inputs/selects in draft dialogs (ParticipantSelectionDialog, TimeBankAdjustmentDialog, AvailableParticipantsSection)
- Add role="dialog" + aria-modal + focus trap to ConnectionOverlay and AuthRecoveryOverlay
- Add aria-live region and connection status announcement to ConnectionOverlay

Serious fixes:
- Add skip-to-content link in root.tsx with id="main-content" on <main>
- Add aria-label to UserMenu trigger button
- Add aria-describedby + role="alert" to all auth form error messages (login, register, onboarding, forgot-password, reset-password)
- Replace emoji column headers in StandingsTable with aria-label + aria-hidden spans
- Add aria-live="assertive" to "It's your turn" desktop and mobile on-clock indicators
- Add aria-live="polite" to draft room countdown timer
- Add pause button to SportTicker (WCAG 2.2.2); add aria-hidden to ticker content
- Fix Footer text contrast (changed from 28% to text-muted-foreground)
- Fix OvernightPauseSettings: add htmlFor/id pairs and role="radiogroup"+aria-checked to mode buttons
- Fix DraftSetupSection: replace broken htmlFor with aria-label on date picker button
- Add aria-label to PeopleSection owner and commissioner selects
- Add labels to ScoringPresetPicker score inputs; add role="radiogroup"+aria-checked to preset buttons
- Add role="radiogroup"+aria-checked to AutodraftSettings option buttons
- Add accessible names, aria-current="step", and <ol> list semantics to WizardStepper

Moderate fixes:
- Add aria-controls to RecentPicksFeed toggle button; wrap picks list in aria-live region
- Add role="tab"+aria-selected+aria-controls to mobile board sub-tabs + role="tabpanel"
- Add role="radiogroup"+aria-checked to TimerModeSelector
- Add aria-current="page" + aria-label to SettingsDesktopNav
- Add aria-label="Admin navigation" to admin sidebar nav
- Add scope="col" + <caption> to StandingsTable and ScoringTables
- Add ARIA table roles (role="table/rowgroup/row/columnheader/rowheader/cell") to DraftSummaryView CSS grid

Minor fixes:
- Add aria-hidden="true" to decorative trend icons in StandingsTable
- Add aria-hidden="true" to desktop column header labels row in AvailableParticipantsSection
- Replace title with aria-label on all icon-only buttons (watchlist, queue) in AvailableParticipantsSection
- Add aria-label to NotificationSettings switchOnly Switch
- Add prefers-reduced-motion check to SlotMachineHeadline JS animation
- Bump --muted-foreground from 55% to 62% opacity for improved contrast margin

https://claude.ai/code/session_01JXajpFxhqLf8aPCncP81k3

* Fix code review findings from WCAG compliance pass

- Add Arrow key navigation + roving tabindex to all role=radiogroup
  components (AutodraftSettings x2, TimerModeSelector,
  OvernightPauseSettings, ScoringPresetPicker) per ARIA radio pattern
- Extract shared focus-trap logic into useFocusTrap hook; update
  ConnectionOverlay and AuthRecoveryOverlay to use it
- Add tabIndex={-1} to ConnectionOverlay Card so focus can land in
  spinner-only state (no interactive children)
- Replace aria-live on loading dots container with sr-only span so
  status changes are announced by text content, not aria-label
- Remove contradictory aria-hidden+role=columnheader from
  AvailableParticipantsSection visual-only header row
- Remove invalid scope="col" from div[role=columnheader] in
  DraftSummaryView (scope is only valid on <th>)
- Remove redundant aria-label from ParticipantSelectionDialog sport
  select (htmlFor label is sufficient)
- Change WizardStepper connector <li> to role=presentation
- Revert muted-foreground from 62% to 55% (original already passes
  contrast; footer was fixed separately via text-muted-foreground)

https://claude.ai/code/session_01JXajpFxhqLf8aPCncP81k3

* Fix lint error and update tests for WCAG role changes

- Replace el! non-null assertion with optional chaining in useFocusTrap
- Update AutodraftSettings tests to query role="radio" instead of
  role="button" (buttons have an explicit radio role since the WCAG pass)
- Update AvailableParticipantsSection watchlist tests to use
  getByRole/getAllByRole instead of getByTitle/getAllByTitle (watchlist
  buttons now use aria-label instead of title)

https://claude.ai/code/session_01JXajpFxhqLf8aPCncP81k3

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-17 20:11:38 -07:00

287 lines
11 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { ChevronDown } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { SectionCardHeader } from "~/components/ui/SectionCardHeader";
import { BRACKT_GRADIENT } from "~/lib/brand";
import { BracketDecor } from "./BracketDecor";
import { SPORTS, HOW_IT_WORKS_STEPS, FEATURES } from "~/lib/landing-data";
// ─── Slot Machine ────────────────────────────────────────────────────────────
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
const TOTAL_TICKS = 12;
// WCAG 2.3.1: no more than 3 flashes/second → minimum interval 334 ms.
// Curve runs 800 ms → 340 ms over 12 ticks (ramps to full speed at tick 8).
function getInterval(tick: number): number {
const pct = Math.min((tick / TOTAL_TICKS) * 1.5, 1);
return Math.round(800 - (800 - 340) * (pct * pct));
}
function SlotMachineHeadline() {
const [text, setText] = useState(SPORTS[0]);
const [landed, setLanded] = useState(false);
const [animKey, setAnimKey] = useState(0);
const tickRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sportsSeq = useRef<string[]>([]);
useEffect(() => {
if (typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setLanded(true);
return;
}
sportsSeq.current = shuffle(SPORTS);
let idx = 0;
function tick() {
tickRef.current++;
const sport = sportsSeq.current[idx % sportsSeq.current.length];
idx++;
setText(sport);
setAnimKey((k) => k + 1);
if (tickRef.current >= TOTAL_TICKS) {
setLanded(true);
return;
}
timerRef.current = setTimeout(tick, getInterval(tickRef.current));
}
timerRef.current = setTimeout(tick, 800);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
if (landed) {
return (
<span
className="animate-sport-land block bg-clip-text text-transparent pb-[0.12em]"
style={{ backgroundImage: BRACKT_GRADIENT }}
>
Every Sport.
</span>
);
}
return (
<span key={animKey} className="animate-spin-in block text-white">
{text}
</span>
);
}
// ─── Scroll Hint ─────────────────────────────────────────────────────────────
function ScrollHint() {
const [visible, setVisible] = useState(true);
useEffect(() => {
const onScroll = () => { if (window.scrollY > 50) setVisible(false); };
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<div
className="pointer-events-none absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce transition-opacity duration-500"
style={{ opacity: visible ? 0.35 : 0 }}
>
<ChevronDown className="h-6 w-6 text-white" />
</div>
);
}
// ─── Sport Ticker ─────────────────────────────────────────────────────────────
const TICKER_ITEMS = [
...SPORTS.map((s) => ({ sport: s.toUpperCase(), key: `${s}-a` })),
...SPORTS.map((s) => ({ sport: s.toUpperCase(), key: `${s}-b` })),
];
function SportTicker() {
const [paused, setPaused] = useState(false);
return (
<div className="relative overflow-hidden pb-px" style={{ marginTop: "52px" }}>
<button
type="button"
onClick={() => setPaused((p) => !p)}
aria-label={paused ? "Resume sport ticker" : "Pause sport ticker"}
className="sr-only focus:not-sr-only focus:absolute focus:top-0 focus:right-0 focus:z-20 focus:px-2 focus:py-1 focus:text-xs focus:bg-background focus:border focus:rounded-md"
>
{paused ? "Resume" : "Pause"}
</button>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-[60px]"
style={{ background: "linear-gradient(to right, #14171e, transparent)" }}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-[60px]"
style={{ background: "linear-gradient(to left, #14171e, transparent)" }}
/>
<div
aria-hidden="true"
className={paused ? "flex" : "flex animate-ticker"}
style={{ width: "max-content" }}
>
{TICKER_ITEMS.map(({ sport, key }) => (
<span
key={key}
className="mx-1.5 shrink-0 rounded-[3px] px-3 py-1 text-[11px] font-bold uppercase tracking-[0.05em] border bg-card text-muted-foreground"
>
{sport}
</span>
))}
</div>
</div>
);
}
// ─── Main Landing Page ────────────────────────────────────────────────────────
export function LandingPage() {
// Interleave step cards with gradient triangle separators
const howItWorksItems: React.ReactNode[] = [];
HOW_IT_WORKS_STEPS.forEach((step, i) => {
howItWorksItems.push(
<Card key={step.title} className="flex-1 gap-2">
<SectionCardHeader icon={step.icon} title={step.title} />
<CardContent className="px-3 sm:px-6">
<p className="text-sm text-muted-foreground">{step.body}</p>
</CardContent>
</Card>
);
if (i < HOW_IT_WORKS_STEPS.length - 1) {
// Triangles reference #brackt-primary-gradient defined in <BracktGradients /> (mounted in root.tsx)
howItWorksItems.push(
<div key={`arrow-${step.title}`} className="flex shrink-0 items-center justify-center py-1 md:px-1 md:py-0">
<svg width="20" height="20" viewBox="0 0 20 20" className="rotate-90 md:rotate-0">
<polygon
points="3,2 18,10 3,18"
fill="url(#brackt-primary-gradient)"
stroke="url(#brackt-primary-gradient)"
strokeWidth={3}
strokeLinejoin="round"
/>
</svg>
</div>
);
}
});
return (
<div className="animate-page-fade-in">
{/* ── Hero ── */}
<section
className="relative flex flex-col items-center justify-center overflow-hidden px-8"
style={{ minHeight: "calc(100vh - var(--navbar-height))" }}
>
{/* Dot-grid texture */}
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.07) 1px, transparent 1px)",
backgroundSize: "32px 32px",
maskImage: "radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 100%)",
WebkitMaskImage: "radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 100%)",
}}
/>
{/* Bracket decorations */}
<div className="lg:hidden">
<BracketDecor mobileTall />
<BracketDecor flip mobileTall />
</div>
<div className="hidden lg:block">
<BracketDecor />
<BracketDecor flip />
</div>
<ScrollHint />
{/* Center content */}
<div className="relative z-10 w-[80%] lg:w-full max-w-[680px] text-center px-6 py-4 lg:px-0 lg:py-0">
<h1
className="mb-0 leading-[0.95] tracking-[-0.04em]"
style={{ fontSize: "clamp(38px, 8vw, 88px)", fontWeight: 900 }}
>
<span className="block text-white">Fantasy</span>
<SlotMachineHeadline />
</h1>
<p
className="mx-auto mt-8 text-muted-foreground"
style={{ fontSize: "16px", fontWeight: 400, lineHeight: 1.65, maxWidth: "414px" }}
>
Draft teams across every league and sport, like the NFL, NHL, EPL, F1, and more.
Compete against your friends in a season-long league.
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-2.5 sm:flex-row">
<Button asChild size="lg" className="text-sm font-extrabold">
<Link to="/leagues/new">Create a League</Link>
</Button>
<Button asChild variant="outline" size="lg" className="text-sm font-semibold">
<Link to="/how-to-play">How it works</Link>
</Button>
</div>
<SportTicker />
</div>
</section>
{/* ── How It Works ── */}
<section className="mx-auto w-full max-w-[1100px] px-10 py-18">
<div className="flex flex-col gap-4 md:flex-row md:items-stretch">
{howItWorksItems}
</div>
</section>
{/* ── Features Grid ── */}
<section className="mx-auto w-full max-w-[1100px] px-10 pb-18">
<div className="overflow-hidden rounded-[6px] border">
<div className="grid grid-cols-1 md:grid-cols-2" style={{ gap: "1px", background: "rgb(255 255 255 / 10%)" }}>
{FEATURES.map(({ title, body }) => (
<div key={title} className="bg-card p-7 transition-colors hover:bg-[#22252e]">
<h3 className="mb-2 text-base font-extrabold tracking-[-0.01em]">{title}</h3>
<p className="text-sm leading-[1.65] text-muted-foreground">{body}</p>
</div>
))}
</div>
</div>
</section>
{/* ── Footer CTA ── */}
<section className="mx-auto w-full max-w-[1100px] px-10 pb-20">
<div className="flex flex-col items-center gap-6 p-12 text-center">
<h2
className="font-black tracking-[-0.025em]"
style={{ fontSize: "clamp(22px, 3vw, 34px)" }}
>
Stop managing your league and start watching it.
</h2>
<p className="text-sm leading-[1.65] text-muted-foreground" style={{ maxWidth: "600px" }}>
Brackt is built for the sports fan who wants a stake in everything without the daily chore of a traditional fantasy roster. Setting up a league takes less than five minutes. Choose your sports, invite your friends, and let the draft decide the winner.
</p>
<Button asChild size="lg" className="text-sm font-extrabold">
<Link to="/leagues/new">Create a League</Link>
</Button>
</div>
</section>
</div>
);
}