diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md
index 5b9422d69..bb919b11f 100644
--- a/ACCESSIBILITY.md
+++ b/ACCESSIBILITY.md
@@ -21,7 +21,7 @@ OffOn is a platform for open source enthusiasts. We want everyone to be able to
- One `
` per page with no skipped heading levels.
- Meaningful `alt` text on informational images, empty `alt=""` paired with `aria-hidden="true"` on decorative ones.
- Screen reader announcement of links that open in a new tab.
-- Color contrast verified at 4.5:1 for body text and 3:1 for large text and UI controls in both modes.
+- Color contrast verified at 7:1 for body text and 4.5:1 for large text (both WCAG AAA), and 3:1 for UI controls, in both modes.
- Tested with [axe-core](https://github.com/dequelabs/axe-core) on every pull request preview, in both light and dark mode.
- Self-hosted fonts so users on restricted networks are not locked out.
- Google Analytics is opt-in only via the consent banner. No tracking runs until the user accepts.
@@ -100,8 +100,8 @@ Apply this to every component you write or modify.
### Color contrast
-- Normal text (under 18px / non-bold under 14px): minimum 4.5:1.
-- Large text (18px+ or bold 14px+): minimum 3:1.
+- Normal text (under 18px / non-bold under 14px): minimum 7:1 (WCAG AAA).
+- Large text (18px+ or bold 14px+): minimum 4.5:1 (WCAG AAA).
- UI components and focus indicators: minimum 3:1 against adjacent colors.
- Focus indicators (WCAG 2.4.11): the focus indicator area must be at least as large as a 2px perimeter outline of the component, and the focused/unfocused contrast ratio must be at least 3:1.
- Never use `hsl(41 100% 60%)` (`#ffc034` yellow) as text in light mode. Fails contrast.
@@ -109,6 +109,8 @@ Apply this to every component you write or modify.
- Never use `opacity-*` on an element that contains visible text. Use an explicit CSS color token instead (e.g. `text-[hsl(var(--text-faint))]`).
- Always verify contrast in both light and dark mode.
- Never rely on color alone to convey meaning. Always pair with text, icon, or pattern.
+- **Hover state contrast in light mode:** `hover:text-primary` resolves to amber (`#ffc034`) on a light surface, which fails contrast. Never use `hover:text-primary` on its own. Use `hover:text-foreground dark:hover:text-primary` so light mode gets a dark, accessible color and dark mode gets the amber accent. Apply the same logic to any interactive element whose hover color differs by mode.
+- **Icon/indicator colors in light mode:** CSS variables like `--difficulty-builder` are set to a pale tint in light mode (`hsl(85 48% 75%)`) and will be near-invisible on light surfaces. Do not use these variables for icon foreground colors. Use a hardcoded accessible value (e.g. `#15803d` for green) that passes contrast in both modes.
### Focus rings
diff --git a/ADVENTURES.md b/ADVENTURES.md
index b333694f2..56eeea5a0 100644
--- a/ADVENTURES.md
+++ b/ADVENTURES.md
@@ -177,10 +177,11 @@ When a new level is ready in the challenges repo after the first adventure PR ha
These scripts run automatically on the hourly schedule but can also be run locally.
```sh
-# Requires DISCOURSE_API_KEY and DISCOURSE_API_USERNAME in .env
-node scripts/refresh-discussions.mjs # Fetch discussion posts for each level
-node scripts/refresh-leaderboard.mjs # Fetch leaderboard data per adventure/level
-node scripts/refresh-community-leaders.mjs # Fetch community leader data
+node scripts/refresh-discussions.mjs # Fetch discussion posts for each level (no credentials needed)
+
+# The following two scripts require DISCOURSE_API_KEY and DISCOURSE_API_USERNAME in .env
+node scripts/refresh-leaderboard.mjs # Fetch leaderboard data per adventure/level
+node scripts/refresh-community-leaders.mjs # Fetch community leader data
```
Create a `.env` file at the repo root for local use:
diff --git a/CLAUDE.md b/CLAUDE.md
index a0c51d7f5..5a26538d8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -91,6 +91,8 @@ public/
refresh-community-sitemap.yml # Daily community sitemap regeneration
sync-adventure.yml # workflow_dispatch: sync an adventure from the challenges repo
validate-adventures.yml # PR check: validates adventure YAML, routes, and sitemap consistency
+ validate-docs.yml # PR check: ensures styleguide.md/README.md updated with code changes
+ add-discussion-url.yml # workflow_dispatch: set discussionUrl for a level and fetch initial posts
```
---
@@ -199,7 +201,7 @@ When diagnosing a bug, especially in the production build, follow these rules wi
- **Author-controlled prose fields contain pre-rendered HTML.** Every YAML/TS field that holds prose written by a challenge author (`level.audience`, `tool.description`, `step.title`, `step.content`, `contributor.about`, `rewards.eligibility`, `tier.description`, `rewards.rankingNote`, `level.learnings`, `level.objective`, `level.intro`, `level.backstory`, `level.scenario`, `level.architecture`, `adventure.story`, `adventure.backstory`) is converted from Markdown to sanitised HTML at build time by `scripts/generate-adventures.mjs`. Always render them with `dangerouslySetInnerHTML={{ __html: value }}` and the `md-inline` (inline prose) or `md-content` (block content) CSS class. Never render as `{value}` directly. Identifier fields (`id`, URLs, enum values like `difficulty`, emoji) are not author prose and are rendered directly.
- **When the container is an interactive element** (e.g. a `` card or a `