From c27a77a7f1f6c0ee19a527fa545da6aeddc3a79b Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli Date: Thu, 28 May 2026 11:53:44 +0200 Subject: [PATCH 1/2] refactor(a11y): improve accessibility, clean up nav and challenges Key changes: - Add RouteAnnouncer in Layout.tsx so SPA navigations are announced to screen readers via a polite live region. - Upgrade Navbar mobile menu with focus trap (Tab wrapping, Escape to close, focus return to trigger), aria-hidden + inert on obscured content, and Challenges nav link now routes to /challenges instead of /#challenges. - Remove TechFilterSection, useActiveSection, and sonner (unused). Drop JetBrains Mono 500-weight font files (600 weight already served; 500 weight was a duplicate load). - Refactor ChallengesGrid: permanent sr-only aria-live region announces both filter-applied and filter-cleared states; add All pill; extract click handlers. - Refactor Challenges page: show all challenge levels by default (flat FilteredLevelCard grid) instead of AdventureCard grid; add All pill and permanent sr-only live region; fix URL construction to use BASE_URL; update page title to "Open Source Challenges". - RewardsCard: add deadlinePast prop; when deadline is past, replace eligibility text with a post-deadline message and Community Voices link (non-compact) or short pill note (compact). Move rewards card below leaderboard in this state. - Add isDeadlinePast utility in src/lib/utils.ts. - Add loader() exports to AdventureDetail and ChallengeDetail that compute rewardsBelowFold at build time. - ConsentBanner: switch aria-label to aria-labelledby on visible title paragraph; move initial focus to Decline button when banner appears. - MarkdownContent CodeBlock pre: add aria-label="Code block" so keyboard-focusable pre has an accessible name. - ChallengeDetail verification pre: add aria-label="Verification command". - Reduce firefly count from 12 to 8. - Add prefers-contrast and forced-colors media query blocks to index.css for OS-level accessibility. - Update Accessibility page, CLAUDE.md, styleguide.md, and smoke/unit/prerender tests to reflect all changes. Signed-off-by: Sinduri Guntupalli --- .github/workflows/deploy.yml | 3 + CLAUDE.md | 8 +- e2e/smoke.spec.ts | 6 +- package-lock.json | 11 -- package.json | 1 - .../jetbrains-mono-latin-500-normal.woff2 | Bin 21832 -> 0 bytes .../jetbrains-mono-latin-ext-500-normal.woff2 | Bin 7528 -> 0 bytes src/Layout.tsx | 28 +++- src/components/ChallengesGrid.tsx | 32 +++- src/components/ConsentBanner.tsx | 16 +- src/components/Hero.tsx | 2 +- src/components/MarkdownContent.tsx | 1 + src/components/Navbar.tsx | 96 +++++++++--- src/components/RewardsCard.tsx | 39 +++-- src/components/TechFilterSection.tsx | 47 ------ src/components/ui/sonner.tsx | 27 ---- src/hooks/useActiveSection.ts | 53 ------- src/index.css | 73 +++++++-- src/lib/utils.ts | 10 ++ src/pages/Accessibility.tsx | 44 ++++++ src/pages/AdventureDetail.tsx | 25 ++- src/pages/ChallengeDetail.tsx | 34 ++-- src/pages/Challenges.tsx | 68 ++++++-- src/test/adventureDetail.test.tsx | 83 +--------- src/test/challengesGrid.test.tsx | 12 +- src/test/consent.test.tsx | 12 +- src/test/navbar.test.tsx | 60 +++++++ src/test/prerender.test.ts | 2 +- src/test/rewardsCard.test.tsx | 145 +++++++++++++++++ src/test/sonner.test.tsx | 77 --------- src/test/techFilterSection.test.tsx | 103 ------------ src/test/useActiveSection.test.ts | 147 ------------------ src/test/utils.test.ts | 38 +++++ styleguide.md | 75 +++++---- 34 files changed, 699 insertions(+), 679 deletions(-) delete mode 100644 public/fonts/jetbrains-mono-latin-500-normal.woff2 delete mode 100644 public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 delete mode 100644 src/components/TechFilterSection.tsx delete mode 100644 src/components/ui/sonner.tsx delete mode 100644 src/hooks/useActiveSection.ts create mode 100644 src/test/rewardsCard.test.tsx delete mode 100644 src/test/sonner.test.tsx delete mode 100644 src/test/techFilterSection.test.tsx delete mode 100644 src/test/useActiveSection.test.ts create mode 100644 src/test/utils.test.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 28c36469..10dde3c0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,9 @@ on: push: branches: [main] workflow_dispatch: + schedule: + # Daily at 02:00 UTC — ensures deadline-based layout changes are reflected without a code push + - cron: "0 2 * * *" permissions: contents: write diff --git a/CLAUDE.md b/CLAUDE.md index 28316eb3..d42c580e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Community activity happens on a separate Discourse instance. Its display name is - **Framework:** React 19 with TypeScript, bundled via Vite. Check `package.json` for current versions. - **Styling:** Tailwind CSS 4, configured CSS-first via `src/index.css` (`@theme` block). There is no `tailwind.config.ts` — it was deleted as part of the Tailwind 4 migration. -- **Components:** Minimal shadcn/ui surface. `src/components/ui/` contains only `badge.tsx`, `sonner.tsx`, and `tooltip.tsx`. Most Radix UI packages were intentionally removed. +- **Components:** Minimal shadcn/ui surface. `src/components/ui/` contains only `badge.tsx` and `tooltip.tsx`. Most Radix UI packages were intentionally removed. - **Routing:** React Router v7 framework mode (static prerendering with `ssr: false`) - **Testing:** Vitest + @testing-library/react (unit/component); Playwright (smoke tests in `e2e/`) - **Hosting:** GitHub Pages @@ -204,14 +204,14 @@ without exception. They exist to prevent debugging by accumulation. ## Components - Always check `src/components/ui/` before building a new primitive. -- `src/components/ui/` contains three files: `badge.tsx`, `sonner.tsx`, `tooltip.tsx`. Adding a new shadcn component requires an immediate use case in the same PR. Unused components are removed. To add one: `npx shadcn@latest add `. +- `src/components/ui/` contains two files: `badge.tsx` and `tooltip.tsx`. Adding a new shadcn component requires an immediate use case in the same PR. Unused components are removed. To add one: `npx shadcn@latest add `. - Never modify files inside `src/components/ui/` directly. Extend or wrap them in `src/components/`. - Page-level components go in `src/pages/`. Reusable components go in `src/components/`. - Extract sub-components into `src/components/` rather than nesting them inline. - Do not duplicate card or list markup across components. If the same JSX structure appears in two places, extract a shared component. `FilteredLevelCard` is the established pattern. - **Buttons:** use raw ` {SUMMARY_TAGS.map((tag) => ( - ))} - -

- {activeTech && relatedLevels.length > 0 - ? `Showing ${relatedLevels.length} challenge${relatedLevels.length !== 1 ? "s" : ""} for ${activeTech}` - : ""} -

- {activeTech && relatedLevels.length > 0 && ( -
- {relatedLevels.map(({ level, adventureId, adventureTitle }) => ( - - ))} -
- )} - - ); -}; diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx deleted file mode 100644 index 191047e4..00000000 --- a/src/components/ui/sonner.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useTheme } from "@/hooks/useTheme"; -import { Toaster as Sonner, toast } from "sonner"; - -type ToasterProps = React.ComponentProps; - -const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme(); - - return ( - - ); -}; - -export { Toaster, toast }; diff --git a/src/hooks/useActiveSection.ts b/src/hooks/useActiveSection.ts deleted file mode 100644 index 0331c818..00000000 --- a/src/hooks/useActiveSection.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect, useState } from "react"; - -/** - * Observes a set of section IDs and returns the ID of whichever section - * is currently intersecting the viewport, or null if none are. - * - * Uses IntersectionObserver (runs off the main thread) so there is no - * scroll event listener and no per-frame work on the main thread. - */ -export function useActiveSection(sectionIds: string[]): string | null { - const [activeId, setActiveId] = useState(null); - - useEffect(() => { - if (typeof window === "undefined" || !("IntersectionObserver" in window)) { - return; - } - if (sectionIds.length === 0) return; - - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (entry.isIntersecting) { - setActiveId(entry.target.id); - return; - } - } - // If no section is intersecting, clear the active id - const anyIntersecting = entries.some((e) => e.isIntersecting); - if (!anyIntersecting) { - setActiveId(null); - } - }, - { - // Fire when at least 20% of the section enters the viewport. - // High enough to avoid spurious triggers at section edges, - // low enough to work on short viewport heights. - threshold: 0.2, - }, - ); - - const elements = sectionIds - .map((id) => document.getElementById(id)) - .filter((el): el is HTMLElement => el !== null); - - elements.forEach((el) => observer.observe(el)); - - return () => { - observer.disconnect(); - }; - }, [sectionIds.join(",")]); // eslint-disable-line react-hooks/exhaustive-deps -- join(",") is a stable serialization; avoids re-runs when the caller passes a new array reference with identical IDs - - return activeId; -} diff --git a/src/index.css b/src/index.css index e64d4a16..15209128 100644 --- a/src/index.css +++ b/src/index.css @@ -14,22 +14,6 @@ font-display: optional; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } -@font-face { - font-family: 'JetBrains Mono'; - src: url('/fonts/jetbrains-mono-latin-ext-500-normal.woff2') format('woff2'); - font-weight: 500; - font-style: normal; - font-display: optional; - unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; -} -@font-face { - font-family: 'JetBrains Mono'; - src: url('/fonts/jetbrains-mono-latin-500-normal.woff2') format('woff2'); - font-weight: 500; - font-style: normal; - font-display: optional; - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} @font-face { font-family: 'JetBrains Mono'; src: url('/fonts/jetbrains-mono-latin-ext-600-normal.woff2') format('woff2'); @@ -1064,3 +1048,60 @@ html { scroll-behavior: auto !important; } } + +/* ─── Increased contrast ─────────────────────────────────── */ +/* macOS/iOS "Increase Contrast" and equivalent OS settings trigger + prefers-contrast: more. Removes opacity from borders and muted elements + that would otherwise fall below user expectations at high contrast. */ +@media (prefers-contrast: more) { + .btn-ghost { + border-color: hsl(var(--foreground)); + } + .btn-ghost-inverse { + border-color: hsl(var(--background)); + } + .pill-inactive { + border-color: hsl(var(--foreground) / 0.7); + color: hsl(var(--foreground)); + } +} + +/* ─── Forced colors (Windows High Contrast Mode) ─────────── */ +/* System color keywords restore interactive component visibility when the + OS overrides all custom colors. focus-visible rules use Highlight so + focus rings remain visible regardless of --ring value. */ +@media (forced-colors: active) { + .btn-primary, + .btn-soft, + .btn-inverse { + border: 2px solid ButtonText; + background-color: ButtonFace; + color: ButtonText; + forced-color-adjust: none; + } + .btn-ghost, + .btn-ghost-inverse { + border-color: ButtonText; + color: ButtonText; + forced-color-adjust: none; + } + .pill-active { + border-color: Highlight; + background-color: Highlight; + color: HighlightText; + forced-color-adjust: none; + } + .pill-inactive { + border-color: ButtonText; + color: ButtonText; + forced-color-adjust: none; + } + .skip-nav:focus { + outline: 3px solid Highlight; + border-color: Highlight; + } + *:focus-visible { + outline: 3px solid Highlight; + outline-offset: 2px; + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 34b5aa50..4be2c8d5 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,3 +5,13 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } + +/** + * Returns true if the deadline string represents a date in the past. + * Handles the format "10 December 2025 at 09:00 CET" by extracting the date portion. + */ +export function isDeadlinePast(deadline: string | undefined): boolean { + if (!deadline) return false; + const date = new Date(deadline.split(" at ")[0].trim()); + return !isNaN(date.getTime()) && date < new Date(); +} diff --git a/src/pages/Accessibility.tsx b/src/pages/Accessibility.tsx index 9c8d7b35..1e3045bd 100644 --- a/src/pages/Accessibility.tsx +++ b/src/pages/Accessibility.tsx @@ -91,6 +91,34 @@ const Accessibility = (): JSX.Element => { Google Analytics is opt-in only via the consent banner. No tracking runs until the user accepts. +
  • + Page navigation is announced to screen readers via a polite live region on every + client-side route change, so users do not have to manually explore to discover + they have moved to a new page. +
  • +
  • + The mobile navigation menu traps focus while open. Content behind the menu is + hidden from assistive technology until the menu is dismissed, preventing screen + reader users from navigating into obscured content by mistake. +
  • +
  • + Interactive filter buttons announce the result count when a filter is applied, and + announce when a filter is cleared, so screen reader users receive confirmation + of both actions. +
  • +
  • + Scrollable code blocks are keyboard-focusable with a descriptive label, so + keyboard users can reach and scroll long code samples. +
  • +
  • + prefers-contrast: more is respected: borders and muted elements + increase in contrast when the user enables Increase Contrast in their OS settings. +
  • +
  • + Windows High Contrast Mode is supported via a forced-colors: active{" "} + media query that restores interactive component boundaries using system color + keywords. +
  • Supported Environments

    @@ -235,6 +263,22 @@ const Accessibility = (): JSX.Element => { Have a question?{" "} Reach out to the team.

    + +
    +

    + The accessibility audit that informed this statement used guidance from{" "} + + mgifford/ACCESSIBILITY.md + , a community resource for accessibility best practices, testing criteria, and + issue severity frameworks. Thank you to Mike Gifford and all contributors for + making that guidance openly available. +

    diff --git a/src/pages/AdventureDetail.tsx b/src/pages/AdventureDetail.tsx index 97dba86d..9c039ab2 100644 --- a/src/pages/AdventureDetail.tsx +++ b/src/pages/AdventureDetail.tsx @@ -1,6 +1,6 @@ import { type JSX } from "react"; -import { useParams, Link } from "react-router"; -import type { MetaFunction } from "react-router"; +import { useParams, Link, useLoaderData } from "react-router"; +import type { MetaFunction, LoaderFunctionArgs } from "react-router"; import { ArrowRight } from "lucide-react"; import { ADVENTURES, type AdventureLevel } from "@/data/adventures"; import { NotFoundPage } from "@/components/NotFoundPage"; @@ -9,13 +9,18 @@ import { Footer } from "@/components/Footer"; import { DifficultyBadge } from "@/components/DifficultyBadge"; import { CollapsibleSection } from "@/components/CollapsibleSection"; import { PersonNameLink } from "@/components/PersonNameLink"; -import { TechFilterSection } from "@/components/TechFilterSection"; import { RewardsCard } from "@/components/RewardsCard"; import { AdventureLeaderboard } from "@/components/AdventureLeaderboard"; import { ContributorBadge } from "@/components/ContributorBadge"; import { TagChips } from "@/components/TagChips"; import { SITE_URL, BRAND_NAME } from "@/data/constants"; import { buildPageMeta } from "@/lib/meta"; +import { isDeadlinePast } from "@/lib/utils"; + +export function loader({ params }: LoaderFunctionArgs): { rewardsBelowFold: boolean } { + const adventure = ADVENTURES.find((a) => a.id === params.id); + return { rewardsBelowFold: isDeadlinePast(adventure?.rewards?.deadline) }; +} export const meta: MetaFunction = ({ params }) => { const adventure = ADVENTURES.find((a) => a.id === params.id); @@ -76,6 +81,7 @@ const AdventureLevelLink = ({ level, adventureId }: AdventureLevelLinkProps): JS const AdventureDetail = (): JSX.Element => { const { id } = useParams<{ id: string }>(); + const { rewardsBelowFold } = useLoaderData<{ rewardsBelowFold: boolean }>(); const adventure = ADVENTURES.find((adventure) => adventure.id === id); if (!adventure) { @@ -121,9 +127,9 @@ const AdventureDetail = (): JSX.Element => { - {adventure.rewards && ( + {adventure.rewards && !rewardsBelowFold && (
    - +
    )} @@ -152,6 +158,12 @@ const AdventureDetail = (): JSX.Element => { )} + {adventure.rewards && rewardsBelowFold && ( +
    + +
    + )} + {/* Sidebar: leaderboard + contributor */} @@ -175,9 +187,6 @@ const AdventureDetail = (): JSX.Element => { -
    - -