Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6e764c6
feat: add multi-contributor credit system for adventures
sinduri-g Sep 3, 2026
0415f95
fix: address code review findings in multi-contributor credit system
sinduri-g Sep 3, 2026
82f1a52
test(a11y): add keyboard trap and context-change-on-focus tests
sinduri-g Sep 3, 2026
6a6221f
fix(a11y): meet WCAG 2.5.8 touch target minimum on contributor links
sinduri-g Sep 3, 2026
7a01406
fix(data): restore solution attribution and script-producible leaderb…
sinduri-g Sep 4, 2026
39df9c4
refactor(credit): derive adventure credit from one shared module
sinduri-g Sep 4, 2026
ca31e80
fix(a11y): keep the contributor pill as its own pointer target
sinduri-g Sep 4, 2026
87364e4
test: cover the multi-contributor credit feature in rendered output
sinduri-g Sep 4, 2026
00c0fab
chore: declare zod explicitly and stop type-checking vendored assets
sinduri-g Sep 4, 2026
49d6fe3
style: lowercase overline label source text
sinduri-g Sep 4, 2026
7f0a476
docs: record the credit rules and two local-only test traps
sinduri-g Sep 4, 2026
29c5d5c
test(vrt): regenerate 18 desktop baselines
sinduri-g Sep 4, 2026
e98024a
fix(scripts): fail the community refresh instead of exiting 0
sinduri-g Sep 4, 2026
1b156d9
fix(ci): validate refreshed data with the build's own schemas
sinduri-g Sep 4, 2026
76ad6b8
docs(adventures): drop ADVENTURE_CATEGORIES, which no longer exists
sinduri-g Sep 4, 2026
eb15982
docs: make AGENTS.md canonical and have CLAUDE.md import it
sinduri-g Sep 4, 2026
314746a
feat(credit): rework how adventure and challenge credit is shown
sinduri-g Sep 4, 2026
89f7d08
fix(test): import Difficulty type in adventure-credit test
sinduri-g Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 23 additions & 45 deletions .github/workflows/refresh-community-data.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,53 +54,25 @@ jobs:
DISCOURSE_API_USERNAME: ${{ secrets.DISCOURSE_API_USERNAME }}
run: node scripts/refresh-community-leaders.mjs

- name: Validate refreshed JSON structure
run: |
set -euo pipefail
ERRORS=0

# Each *-posts.json must have a "discussionPosts" array
for f in src/data/adventures/*/*-posts.json; do
if ! FILE="$f" node -e "
const d = JSON.parse(require('fs').readFileSync(process.env.FILE, 'utf-8'));
if (!Array.isArray(d.discussionPosts)) { console.error('❌ ' + process.env.FILE + ': missing discussionPosts array'); process.exit(1); }
" 2>&1; then
ERRORS=$((ERRORS + 1))
fi
done

# Each leaderboard.json must have a "rows" array
for f in src/data/adventures/*/leaderboard.json; do
if ! FILE="$f" node -e "
const d = JSON.parse(require('fs').readFileSync(process.env.FILE, 'utf-8'));
if (!Array.isArray(d.rows)) { console.error('❌ ' + process.env.FILE + ': missing rows array'); process.exit(1); }
" 2>&1; then
ERRORS=$((ERRORS + 1))
fi
done

# community-leaders.json must have a "sections" array
if ! node -e "
const d = JSON.parse(require('fs').readFileSync('src/data/community-leaders.json', 'utf-8'));
if (!Array.isArray(d.sections)) { console.error('❌ community-leaders.json: missing sections array'); process.exit(1); }
" 2>&1; then
ERRORS=$((ERRORS + 1))
fi

if [[ $ERRORS -gt 0 ]]; then
echo "VALIDATION_FAILED=true" >> "$GITHUB_ENV"
echo "❌ JSON validation failed. Aborting commit to protect main."
exit 1
else
echo "✓ All refreshed JSON files are structurally valid."
fi

- name: Open issue on validation failure
# Validates with the build's own parsers (parseCommunityLeadersData,
# getDiscussion, getLeaderboard) rather than a weaker Array.isArray check.
# The old gate passed data the deploy build would later reject, for example
# a section id Discourse newly starts producing that is not in SECTION_IDS,
# which then reached main before failing. Whatever the build rejects, this
# rejects first, before the commit step runs.
- name: Validate refreshed data against the build schemas
run: node scripts/validate-refreshed-data.mjs

# Fires on any earlier step failing, which since the refresh scripts gained
# their own failure thresholds now covers fetch failures as well as schema
# validation. The title stays cause-neutral so it does not mislabel a
# Discourse outage as a validation problem.
- name: Open issue on refresh failure
if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TITLE="Refresh community data: JSON validation failed"
TITLE="Refresh community data: run failed, data not updated"
OPEN=$(gh issue list \
--state open \
--search "\"${TITLE}\" in:title" \
Expand All @@ -110,11 +82,17 @@ jobs:
gh issue create \
--title "${TITLE}" \
--label "bug" \
--body "The scheduled \`refresh-community-data\` workflow failed JSON structure validation and did not commit new data to \`main\`.
--body "The scheduled \`refresh-community-data\` workflow failed and did not commit new data to \`main\`. Community data on the site is now stale and will stay stale until this is fixed.

**Run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

Check the run log for which file failed and which field was missing or malformed. Fix the upstream script that writes that file, then re-run the workflow manually via workflow_dispatch."
The run log names the cause. The three classes are:

- **Discourse fetch failures.** \`refresh-discussions.mjs\` fails when more than one topic errors, or when every attempted topic errors. It lists each failing topic URL with its reason, which distinguishes one bad or deleted discussion URL (a single \`HTTP 404\`) from Discourse being down or rate-limiting (\`fetch failed\` / \`HTTP 429\` across the board).
- **Missing credentials.** \`refresh-leaderboard.mjs\` and \`refresh-community-leaders.mjs\` exit 1 in CI when \`DISCOURSE_API_KEY\` is unset, which usually means the secret was rotated or removed.
- **Schema validation.** \`validate-refreshed-data.mjs\` failed, meaning refreshed data no longer matches the schemas the build uses. It names the file and the offending field. A new Discourse section id needs adding to \`SECTION_IDS\` in \`src/lib/community-leaders.ts\` (and to \`SECTION_ICON_NAMES\` in \`CommunityLeaders.astro\`).

Fix the cause, then re-run the workflow manually via workflow_dispatch."
fi

- name: Commit if changed
Expand Down
20 changes: 17 additions & 3 deletions ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The following WCAG 2.2 Level AAA criteria are actively targeted on this site:
| 1.4.6 Contrast (Enhanced) | Body text targets 7:1; large text targets 4.5:1 in both modes. |
| 2.4.9 Link Purpose (Link Only) | Every link must make sense without surrounding context. Card links use `aria-label` with the specific item name. Ambiguous text like "via email" is rewritten or wrapped in a descriptive label. |
| 2.4.10 Section Headings | Headings are used to organize all content sections, including sidebar sub-sections which use `<h3>`. |
| 2.4.13 Focus Appearance (Enhanced) | Focus indicators target a minimum 2px perimeter, ≥3:1 contrast against the unfocused state, and a minimum enclosed area. Verified by full keyboard traversal in `e2e/a11y.spec.ts` (dark and light modes). |
| 3.1.3 Unusual Words | Technical terms (e.g. devcontainer) are defined on first use via `<abbr title="…">` or inline expansion. |
| 3.1.4 Abbreviations | Abbreviations are expanded on first use per page: `<abbr title="…">` for inline HTML; written out in full for plain-text contexts (e.g. "Site Reliability Engineers (SREs)"). |
| 3.1.5 Reading Level | General copy targets plain language. Technical content is inherent to the subject; abbreviations and unusual words are expanded on first use. Challenge-specific content is authored by contributors and may be technical by nature. |
Expand Down Expand Up @@ -64,8 +65,20 @@ If you find a barrier that is not listed here, please report it using the link b

### Automated

- **axe-core via Playwright** on every pull request, configured in [`e2e/a11y.spec.ts`](e2e/a11y.spec.ts). Runs in both dark and light mode against the production build with tags `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, `wcag22aa`, and `best-practice`. The PR preview workflow blocks on these scans. Never reduce this tag set.
- Automated tests are Playwright-only (`e2e/`). Unit tests for library logic are a known gap.
All automated checks run in [`e2e/a11y.spec.ts`](e2e/a11y.spec.ts) against the production build via Playwright on every pull request. The PR preview workflow blocks on these scans.

| Check | WCAG | Notes |
| --- | --- | --- |
| axe-core (dark mode) | Full tag set: `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, `wcag22aa`, `best-practice` | Never reduce this tag set. |
| axe-core (light mode) | same | `.light` class set via localStorage before navigation. |
| axe-core (forced colors) | same minus `color-contrast` | Emulates Windows High Contrast Mode. `color-contrast` excluded: emulation fires the media query but does not remap computed colors, producing false positives. |
| Touch target minimum size | 2.5.8 | Every non-inline interactive element in the viewport is ≥24×24px. |
| Focus ring traversal (dark + light) | 2.4.7, 2.4.13 | Tabs through every focusable element on every page; fails any element with no `outline` or `box-shadow` on `:focus-visible`. |
| Skip link | 2.4.1 | First Tab stop is the skip link; activating it moves focus to `#main-content`. Tested on a representative route sample. |
| Keyboard trap detection | 2.1.2 | Tabs through every page; detects repeating focus patterns (cycle length 1–5) that exclude the page's first focusable element, indicating focus is stuck. |
| Context change on focus | 3.2.1 | Tabs through every page; fails if the URL changes after a Tab press (navigation triggered by focus). |
| Zoom/reflow | 1.4.10 | Viewport set to 384px (equivalent to 200% zoom on 768px); asserts no horizontal `scrollWidth` overflow. |
| Very small text | — | No visible text node below 10px (WAVE "very small text" threshold). |

Automated axe passes are necessary but not sufficient. Automated tools catch roughly 30–40% of real-world accessibility issues. Manual testing is required for every interactive component.

Expand Down Expand Up @@ -197,7 +210,7 @@ We aim to acknowledge accessibility reports within five working days and to prov

## For Contributors

Every UI change must pass the checklist below before the PR is submitted. See [`CLAUDE.md`](CLAUDE.md) for project conventions.
Every UI change must pass the checklist below before the PR is submitted. See [`AGENTS.md`](AGENTS.md) for project conventions.

---

Expand Down Expand Up @@ -429,6 +442,7 @@ Use this to identify which criterion applies before writing or reviewing code.
| | 3.1.3 Unusual Words | **AAA** | Technical terms defined on first use via `<abbr title="…">` or inline expansion. |
| | 3.1.4 Abbreviations | **AAA** | Abbreviations expanded on first use per page. |
| | 3.1.5 Reading Level | **AAA** | General copy targets plain language; technical terms expanded on first use. |
| | 3.2.1 On Focus | A | Focusing an element must not trigger a context change (navigation, form submission, or any other automatic change). |
| | 3.3.1 Error Identification | A | Error messages identify the field and describe the error. |
| | 3.3.2 Labels or Instructions | A | Form fields have labels; placeholders are not substitutes. |
| **Robust (4.x)** | 4.1.2 Name, Role, Value | A | ARIA roles and attributes are valid. Dynamic state (`aria-expanded`, `aria-current`) is kept in sync. |
Expand Down
36 changes: 31 additions & 5 deletions ADVENTURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The authoritative schema is in [`src/content.config.ts`](src/content.config.ts)
| `story` | Optional | markdown string | Short description shown on adventure cards and at the top of the adventure page. Card views strip HTML; set:html prose uses the rendered version. |
| `backstory` | Optional | `string[]` (markdown) | Thematic narrative paragraphs rendered on the adventure page. |
| `overview` | Optional | `string[]` (markdown) | Technical/content summary rendered on the adventure page. |
| `contributor` | Optional | object | `name` (required), `url` (optional URL), `about` (optional markdown). Survives every re-sync once set. |
| `contributor` | Optional | object | `name` (required), `url` (optional URL), `about` (optional markdown), `discourse_username` (optional string -- Discourse username used for avatar resolution in community leaderboards). Survives every re-sync once set. |
| `community_category_id` | Optional | integer | Discourse category ID. Survives every re-sync once set; position is kept directly after `slug`. |
| `rewards` | Optional | object | `deadline` (required inside; see format below), `eligibility` (markdown), `tiers` (array of `{label, description}`), `ranking_note` (markdown), `ranking_rules_url` (URL). |
| `upcoming_levels` | Optional | object[] | Coming-soon placeholders: `{level?, name, difficulty}`. Survives re-syncs for levels not yet in the challenges repo. |
Expand Down Expand Up @@ -87,9 +87,14 @@ Each entry in the `levels` array accepts the following fields.
| `verification` | **Required** | object | `{command, description}` — the verification gate command and its description. |
| `codespaces_machine` | Optional | `"4core"` | Machine size override for Codespaces. Only `"4core"` is accepted; other values fail the Zod schema. |
| `hook` | Optional | string | Verification hook command. |
| `contributor` | Optional | object | Person who built this specific level. Same subfields as the adventure `contributor` (`name`, `url`, `about`, `discourse_username`). **When omitted, the adventure designer is credited as the builder for this level.** When set, takes precedence over the adventure designer for credit display on the level page and in community leaderboard sections. See note below. |
| `solved_count` | Optional | integer | Override for the displayed solved count. |
| `top_players` | Optional | object[] | System-populated leaderboard data: `{username, count}`. Set by the leaderboard refresh script; do not edit by hand. |

**Level `contributor:` and the designer-as-builder rule.** The credit rule is `level.contributor ?? adventure.contributor`, applied per level: a designer who builds two of three levels keeps credit for those two while a guest builder takes the third. Omitting `contributor:` from a level does not mean "no builder known" — it means the adventure designer built that level.

This makes absent `contributor:` ambiguous once real per-level builders exist alongside designer-built levels: the omission could mean "the designer built it" or "we have not yet recorded who built it." The PR checklist's "add contributor" step closes this gap in practice. If a level ever ships with a genuinely unknown builder, the fix is to allow `contributor: null` explicitly: update `src/content.config.ts` to accept `z.nullable()` on the level contributor field, treat explicit `null` as "no credit" in `builderOfLevel` in `src/lib/adventure-credit.ts`, and render nothing on the level page sidebar pill when the builder resolves to `null`. No data migration is needed — absent and `null` are both currently unset.

---

## Syncing a New Adventure
Expand All @@ -109,7 +114,7 @@ Go to **Actions → Sync Adventure from Challenges Repo → Run workflow**.
2. If a PR branch (`feat/adventure-<slug>`) already exists, restores `adventure.yaml` from that branch so any manual edits already made survive the re-sync.
3. Fetches `docs/index.yaml` and all level YAMLs from the challenges repo.
4. Writes `src/data/adventures/<slug>/adventure.yaml` and creates `<level>-posts.json` stubs for each new live level.
5. Validates the YAML with `astro sync` (Zod content schema) and registers the adventure in `ADVENTURE_CATEGORIES` (`scripts/refresh-leaderboard.mjs`). Routes and sitemap entries are automatic via `getStaticPaths()` and `src/pages/sitemap.xml.ts`. `public/llms.txt` is updated by hand as part of the PR checklist.
5. Validates the YAML with `astro sync` (Zod content schema). There is no leaderboard registry to update: `buildAdventureCategories()` in `scripts/refresh-leaderboard.mjs` reads `community_category_id` out of every `adventure.yaml` at runtime, so setting that field (a PR checklist item below) is the whole registration step. Routes and sitemap entries are automatic via `getStaticPaths()` and `src/pages/sitemap.xml.ts`. `public/llms.txt` is updated by hand as part of the PR checklist.
6. Opens (or updates) a PR on `feat/adventure-<slug>` with a checklist of steps to complete before merging.

---
Expand All @@ -125,9 +130,10 @@ contributor:
name: "Full Name"
url: "https://example.com"
about: "One sentence bio."
discourse_username: "their_forum_username"
```

Add this to `src/data/adventures/<slug>/adventure.yaml`. The `url` and `about` fields are optional but recommended. Once set, this block survives future re-syncs automatically.
Add this to `src/data/adventures/<slug>/adventure.yaml`. The `url`, `about`, and `discourse_username` fields are optional but recommended -- `discourse_username` enables avatar resolution in community leaderboards. Once set, this block survives future re-syncs automatically.

### Confirm month

Expand Down Expand Up @@ -305,10 +311,10 @@ public/solutions/<adventure-id>/<level-id>-*.webp ← converted images (commit t
| --- | --- | --- |
| `sync-adventure.yml` | Manual (`workflow_dispatch`) | Sync adventure content from the challenges repo and open or update a PR |
| `add-discussion-url.yml` | Manual (`workflow_dispatch`) | Set a Discourse thread URL for a level after it has been merged, and open a PR with updated YAML and initial posts |
| `validate-adventures.yml` | PR (when adventure files change) | Validate adventure YAML against the Zod content schema (`astro sync`), check per-level discussion JSON exists, verify `ADVENTURE_CATEGORIES` registration |
| `validate-adventures.yml` | PR (when adventure files change) | Validate adventure YAML against the Zod content schema (`astro sync`), check every live level has its `*-posts.json`, verify the `SKILL.md` digest matches `index.json` |
| `deploy.yml` | Push to `main` | Build and deploy to GitHub Pages at [offon.dev](https://offon.dev) |
| `preview.yml` | Open PR | Deploy a PR preview at `/pr-preview/pr-<n>/` |
| `refresh-community-data.yml` | Hourly + manual | Refresh discussion posts, leaderboard data, and community leaders from Discourse |
| `refresh-community-data.yml` | Hourly + manual | Refresh discussion posts, leaderboard data, and community leaders from Discourse, validate the result against the build's schemas, and commit only if it passes |
| `refresh-community-sitemap.yml` | Daily (05:00 UTC) + manual | Regenerate and commit the community Discourse sitemap |

---
Expand All @@ -323,8 +329,28 @@ node scripts/refresh-discussions.mjs # Fetch discussion posts for each level (
# 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

# Validates everything the three scripts above wrote, using the schemas the build
# itself uses. The refresh workflow runs this before committing. Run from the repo root.
node scripts/validate-refreshed-data.mjs
```

### How the refresh scripts fail

These scripts are the only thing standing between a Discourse outage and the site
serving stale community data forever, so they fail loudly rather than skipping.

| Script | Fails when |
| --- | --- |
| `refresh-discussions.mjs` | More than one topic errors in a run, or every attempted topic errors. Exactly one failure is tolerated so a single deleted thread cannot block every other topic's update, but it is logged as a warning naming the topic, so a failure that repeats every hour stays visible. The error lists each failing topic URL and its reason, which is what distinguishes one bad URL from Discourse being down. |
| `refresh-leaderboard.mjs` | `DISCOURSE_API_KEY` is unset **and** `CI` is set; any Data Explorer query errors; or no adventure has a `community_category_id`. Without a key locally it still skips with exit 0. |
| `refresh-community-leaders.mjs` | `DISCOURSE_API_KEY` is unset **and** `CI` is set, or either Data Explorer query errors. Skips with exit 0 locally. |
| `validate-refreshed-data.mjs` | Any refreshed file fails the schema the build uses. Most often a Discourse section id that is not yet in `SECTION_IDS` (`src/lib/community-leaders.ts`); add it there and to `SECTION_ICON_NAMES` in `CommunityLeaders.astro`. |

The CI-only rule on the API key is deliberate: locally a missing key is a
convenience, but in CI it means the secret was rotated or removed, and exiting 0
there would give a green run with data frozen indefinitely.

Create a `.env` file at the repo root for local use:

```sh
Expand Down
Loading
Loading