diff --git a/.github/workflows/refresh-community-data.yml b/.github/workflows/refresh-community-data.yml index 7a5e2e819..7b64fdd55 100644 --- a/.github/workflows/refresh-community-data.yml +++ b/.github/workflows/refresh-community-data.yml @@ -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" \ @@ -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 diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md index b11c117ac..86fed4b5a 100644 --- a/ACCESSIBILITY.md +++ b/ACCESSIBILITY.md @@ -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 `

`. | +| 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 `` or inline expansion. | | 3.1.4 Abbreviations | Abbreviations are expanded on first use per page: `` 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. | @@ -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, both on a direct load and after arriving by a real link click. The link-click cases matter: nothing may move focus on load, or the skip link stops being the first Tab stop on every in-site navigation while the direct-load tests still pass. | +| 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. @@ -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. --- @@ -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 `` 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. | diff --git a/ADVENTURES.md b/ADVENTURES.md index 25d556cc0..e1de41a50 100644 --- a/ADVENTURES.md +++ b/ADVENTURES.md @@ -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. | @@ -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 @@ -109,7 +114,7 @@ Go to **Actions → Sync Adventure from Challenges Repo → Run workflow**. 2. If a PR branch (`feat/adventure-`) 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//adventure.yaml` and creates `-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-` with a checklist of steps to complete before merging. --- @@ -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//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//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 @@ -305,10 +311,10 @@ public/solutions//-*.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-/` | -| `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 | --- @@ -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. A topic counts as errored when **any** of its pagination chunks fails, not just the first request: a partial fetch would write a file that is structurally valid and missing replies, and the stored posts are the tail of the thread, so a dropped page silently removes activity and solver credit. Failing the topic leaves the previous file in place, stale but correct. | +| `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 diff --git a/AGENTS.md b/AGENTS.md index bb6ccb632..387915a26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,9 @@ Guidance for AI coding agents working in this repository. -> This is the vendor-neutral version of the project AI guidelines. It works with any AI assistant — paste it as a system prompt or load it into your tool's context. Claude Code users: your tool auto-loads `CLAUDE.md`, which contains the same rules plus Claude-specific slash commands and hooks. Keep both files in sync when updating project guidelines. +> **This is the canonical source for the project AI guidelines.** It is vendor-neutral and works with any AI assistant: paste it as a system prompt or load it into your tool's context. +> +> **Claude Code users:** you do not need to do anything. `CLAUDE.md` is auto-loaded and imports this file with `@AGENTS.md`, so you get everything here plus the Claude-specific slash-command table. Do not copy guidance into `CLAUDE.md`; it belongs here, where every tool can read it. --- @@ -10,17 +12,17 @@ Guidance for AI coding agents working in this repository. Workflow-specific AI prompts live in [`.claude/commands/`](.claude/commands/). Any AI assistant can use them: paste the relevant file as a system prompt or opening message. The YAML frontmatter block at the top is harmless and can be ignored. -**Claude Code users:** invoke them as slash commands — `/command-name` in Claude Code. +**Claude Code users:** invoke them as slash commands, `/command-name`. The same table with Claude-specific invocation notes is in [`CLAUDE.md`](CLAUDE.md). | Prompt | File | When to use | | --- | --- | --- | | a11y-audit | [`.claude/commands/a11y-audit.md`](.claude/commands/a11y-audit.md) | On-demand accessibility audit using the Red Team / Blue Team persona pipeline. Run against a component or page to get a severity-weighted report. | -|   keyboard | [`.claude/commands/keyboard.md`](.claude/commands/keyboard.md) | Sub-prompt: writing or reviewing any interactive element — buttons, modals, dropdowns, tabs, custom widgets. | -|   navigation | [`.claude/commands/navigation.md`](.claude/commands/navigation.md) | Sub-prompt: working on nav components — primary nav, skip links, breadcrumbs, pagination, mobile menus. | +|   keyboard | [`.claude/commands/keyboard.md`](.claude/commands/keyboard.md) | Sub-prompt: writing or reviewing any interactive element, such as buttons, modals, dropdowns, tabs, custom widgets. | +|   navigation | [`.claude/commands/navigation.md`](.claude/commands/navigation.md) | Sub-prompt: working on nav components, such as primary nav, skip links, breadcrumbs, pagination, mobile menus. | |   progressive-enhancement | [`.claude/commands/progressive-enhancement.md`](.claude/commands/progressive-enhancement.md) | Sub-prompt: building any new feature or reviewing architecture. Ensures core content works without JS. | |   user-personalization | [`.claude/commands/user-personalization.md`](.claude/commands/user-personalization.md) | Sub-prompt: working on theme toggle, consent state, or any user preference persistence. | -| add-solution | [`.claude/commands/add-solution.md`](.claude/commands/add-solution.md) | Generate a structured TypeScript solution file from any input format (md, YAML, HTML, plain text). Downloads and converts images to WebP. | -| create-presentation | [`.claude/commands/create-presentation.md`](.claude/commands/create-presentation.md) | Create a presentation deck for an OffOn event or challenge. Supports two formats: Reveal.js HTML and editable PowerPoint PPTX (edit and run `node .ai/templates/generate-pptx.mjs`). | +| add-solution | [`.claude/commands/add-solution.md`](.claude/commands/add-solution.md) | Generate a structured TypeScript solution file (`src/data/solutions//.ts`) from any input format (md, YAML, HTML, plain text). Downloads and converts images to WebP. Solutions are pre-built TS objects loaded by the app; there is no generator step. | +| create-presentation | [`.claude/commands/create-presentation.md`](.claude/commands/create-presentation.md) | Create a presentation deck for an OffOn event or challenge. Supports two formats: Reveal.js HTML (`public/deck-template/index.html`) and editable PowerPoint PPTX (edit and run `.ai/templates/generate-pptx.mjs`). Reveal.js output goes to `public//index.html`; PPTX outputs to `public/downloads/offon-deck-template.pptx`. | A `spec-first-coding` prompt is available for Claude Code users (installed globally at `~/.claude/skills/`). It enforces W3C spec citations before generating any accessibility-related code. For other AI tools, cite the relevant W3C spec manually before implementing any accessibility feature. @@ -41,25 +43,25 @@ Use the `a11y-audit` prompt for all accessibility audits in this repo. ## Project Overview -**offon.dev** is the main website for OffOn, a platform for open source enthusiasts. It is fully static with no backend and no database. Pages are prerendered at build time by **Astro** (`output: 'static'`); interactivity is added as `.astro` components with vanilla ` diff --git a/src/lib/adventure-credit.ts b/src/lib/adventure-credit.ts new file mode 100644 index 000000000..7f96afe74 --- /dev/null +++ b/src/lib/adventure-credit.ts @@ -0,0 +1,275 @@ +import type { Difficulty } from "@/lib/difficulty"; + +// Single source of truth for "who gets credit for what" across the adventure +// collection. Four surfaces consume this — AdventureCard, the adventure page +// aside, ChallengeBuildersSection and CommunityLeaders — and they previously +// each derived it inline with subtly different rules, so the same person could +// be credited with two different level counts on one page. +// +// The rule: a level is built by its own `contributor` when it has one, and by +// the adventure `contributor` (the designer) otherwise. The fallback is +// per-level, not all-or-nothing: a designer who builds two of three levels +// keeps credit for those two while a guest builder takes the third. +// +// Pure functions over plain data — no `astro:content` import — so the rules are +// directly unit-testable without a build. + +export type CreditPerson = { + name: string; + url?: string; + discourseUsername?: string; + aboutHtml?: string; +}; + +export type CreditLevel = { + difficulty: Difficulty; + contributor?: CreditPerson; +}; + +export type CreditAdventure = { + slug: string; + title: string; + contributor?: CreditPerson; + levels: CreditLevel[]; +}; + +/** The part of an adventure the credit rules actually read. */ +export type CreditSource = Pick; + +// Name and title ordering is rendered output: it feeds the Challenge +// Contributors cards, the leaderboard rows, the VRT baselines and the e2e +// order assertions. A bare `localeCompare()` collates in whatever locale the +// machine running the build happens to have, so the locale is pinned here and +// the same YAML always produces the same order on every runner. +const collator = new Intl.Collator("en"); +const compareText = (a: string, b: string): number => collator.compare(a, b); + +/** Who built this level: its own contributor, else the adventure designer. */ +export function builderOfLevel( + level: CreditLevel, + adventure: Pick, +): CreditPerson | undefined { + return level.contributor ?? adventure.contributor; +} + +/** A person who built at least one level of an adventure, with which levels. */ +export type LevelBuilder = CreditPerson & { + difficulties: Difficulty[]; + /** Levels credited, which can exceed `difficulties.length` if two share a difficulty. */ + levelCount: number; +}; + +/** + * Everyone credited with building a level of this adventure, keyed by name and + * ordered by the first level they appear on. Includes the designer when the + * fallback credits them. + */ +export function levelBuildersOf(adventure: CreditSource): LevelBuilder[] { + const byName = new Map(); + for (const level of adventure.levels) { + const person = builderOfLevel(level, adventure); + if (!person) continue; + let entry = byName.get(person.name); + if (!entry) { + entry = { ...person, difficulties: [], levelCount: 0 }; + byName.set(person.name, entry); + } + if (!entry.difficulties.includes(level.difficulty)) entry.difficulties.push(level.difficulty); + entry.levelCount++; + } + return [...byName.values()]; +} + +/** One rendered credit: a role label and the person it applies to. */ +export type PillCredit = { + label: string; + person: CreditPerson; +}; + +/** + * The single credit shown on an adventure card and the adventure page title. + * + * Always exactly one person, the designer. The label says whether they also + * built the whole thing: + * + * "Designer & Builder" they designed it and built every challenge + * "Designer" someone else built at least one challenge + * + * The label is about the designer's own scope, never about who the other + * builders are, so the pill stays a compact identity marker rather than a + * credits ledger. Challenge cards carry no credit at all, and per-challenge + * attribution lives on the level pages and in the adventure page aside. + * + * An adventure with no designer has no level builders either, because the + * content schema rejects that combination (see `creditIntegrityError`), so this + * returns null rather than promoting a builder into the pill. The no-designer + * case is real: `sync-adventure.mjs` deliberately omits `contributor`, and a + * reviewer adds it as a PR checklist item. + */ +export function adventurePillCredit(adventure: CreditSource): PillCredit | null { + const designer = adventure.contributor; + if (!designer) return null; + const builtEveryChallenge = + adventure.levels.length > 0 && + adventure.levels.every((l) => builderOfLevel(l, adventure)?.name === designer.name); + return { + label: builtEveryChallenge ? "Designer & Builder" : "Designer", + person: designer, + }; +} + +const DIFFICULTY_ORDER: Record = { Beginner: 0, Intermediate: 1, Expert: 2 }; + +/** + * Difficulties in curriculum order, easiest first, whatever order the levels + * were authored in. Returns a new array; the input is not mutated. + * + * Used wherever a person's built levels are rendered as badges, so two people + * on the same adventure never show their levels in different orders. + */ +export function sortDifficulties(difficulties: Difficulty[]): Difficulty[] { + return [...difficulties].sort((a, b) => DIFFICULTY_ORDER[a] - DIFFICULTY_ORDER[b]); +} + +/** + * Rejects the one credit shape the design forbids: levels naming their own + * builder on an adventure that names no designer. Every adventure has exactly + * one designer by definition, so this is authoring error, not a state to render. + * + * Returns the error message, or null when the adventure is valid. Enforced by + * the content collection schema, so it fails `astro sync` and the build. + */ +export function creditIntegrityError(adventure: { + slug: string; + contributor?: unknown; + levels: { contributor?: unknown }[]; +}): string | null { + if (adventure.contributor) return null; + const withBuilder = adventure.levels.filter((l) => l.contributor).length; + if (withBuilder === 0) return null; + return ( + `Adventure "${adventure.slug}": ${withBuilder} level(s) set their own \`contributor\`, ` + + "but the adventure has no top-level `contributor`. Every adventure needs a designer " + + "before its levels can credit separate builders. Add a `contributor:` block " + + "(name, url, about, discourse_username) at the top level of adventure.yaml." + ); +} + +/** + * The single credit shown on a level page: whoever built this challenge. + * + * Always "Challenge Builder", whether that is a guest or the designer falling + * through. The page is about one challenge, so the question it answers is "who + * built this", and splitting the label by whether the builder also designed the + * adventure made the same fact read two different ways. + */ +export function levelPillCredit( + designer: CreditPerson | undefined, + levelContributor: CreditPerson | undefined, +): PillCredit | null { + const person = levelContributor ?? designer; + return person ? { label: "Challenge Builder", person } : null; +} + +// --------------------------------------------------------------------------- +// Challenge Contributors: one card per person, listing every adventure they +// touched. Which levels, and in what capacity, is deliberately not shown: the +// section thanks people, and per-level detail lives on the adventure pages. +// --------------------------------------------------------------------------- + +export type Contribution = { + slug: string; + title: string; +}; + +export type ContributorEntry = { + name: string; + url?: string; + aboutHtml?: string; + contributions: Contribution[]; +}; + +/** + * Every contributor across the collection, sorted by breadth of contribution + * then name, each with their adventures sorted by title. Designers and level + * builders are both included, and someone who is both appears once. + * + * Keyed by display name: `discourse_username` is optional and absent for most + * contributors, so name is the only key present on every record. + */ +export function buildContributorIndex(adventures: CreditAdventure[]): ContributorEntry[] { + type Draft = Omit & { + contributions: Map; + }; + const byName = new Map(); + + const entryFor = (person: CreditPerson): Draft => { + let entry = byName.get(person.name); + if (!entry) { + entry = { name: person.name, contributions: new Map() }; + byName.set(person.name, entry); + } + entry.url ??= person.url; + entry.aboutHtml ??= person.aboutHtml; + return entry; + }; + + const noteContribution = (entry: Draft, adventure: CreditAdventure): void => { + if (entry.contributions.has(adventure.slug)) return; + entry.contributions.set(adventure.slug, { slug: adventure.slug, title: adventure.title }); + }; + + for (const adventure of adventures) { + if (adventure.contributor) noteContribution(entryFor(adventure.contributor), adventure); + for (const builder of levelBuildersOf(adventure)) noteContribution(entryFor(builder), adventure); + } + + return [...byName.values()] + .map((entry) => ({ + name: entry.name, + ...(entry.url ? { url: entry.url } : {}), + ...(entry.aboutHtml ? { aboutHtml: entry.aboutHtml } : {}), + contributions: [...entry.contributions.values()].sort((a, b) => compareText(a.title, b.title)), + })) + .sort((a, b) => b.contributions.length - a.contributions.length || compareText(a.name, b.name)); +} + +// --------------------------------------------------------------------------- +// CommunityLeaders: counts per person for the derived leaderboard sections. +// --------------------------------------------------------------------------- + +export type CreditCount = { + name: string; + discourseUsername?: string; + count: number; +}; + +const rankByCount = (a: CreditCount, b: CreditCount): number => + b.count - a.count || compareText(a.name, b.name); + +/** Levels built per person, highest first. Same rule as `builderOfLevel`. */ +export function challengeCounts(adventures: CreditAdventure[]): CreditCount[] { + const byName = new Map(); + for (const adventure of adventures) { + for (const builder of levelBuildersOf(adventure)) { + const entry = byName.get(builder.name) ?? { name: builder.name, count: 0 }; + entry.discourseUsername ??= builder.discourseUsername; + entry.count += builder.levelCount; + byName.set(builder.name, entry); + } + } + return [...byName.values()].sort(rankByCount); +} + +/** Adventures designed per person, highest first. */ +export function designerCounts(adventures: CreditAdventure[]): CreditCount[] { + const byName = new Map(); + for (const { contributor } of adventures) { + if (!contributor) continue; + const entry = byName.get(contributor.name) ?? { name: contributor.name, count: 0 }; + entry.discourseUsername ??= contributor.discourseUsername; + entry.count++; + byName.set(contributor.name, entry); + } + return [...byName.values()].sort(rankByCount); +} diff --git a/src/lib/community-data.ts b/src/lib/community-data.ts index 3688aff8b..9fee491d1 100644 --- a/src/lib/community-data.ts +++ b/src/lib/community-data.ts @@ -10,6 +10,13 @@ import { z } from "zod"; // Resolve from process.cwd() (the project root during `astro build`), not // import.meta.url: this module is bundled by Vite for page rendering, which // rewrites import.meta.url and would break a file-relative path. +// +// TESTING TRAP: this is evaluated once, when the module is first imported, so +// the path is captured at import time. A later process.chdir() does not move it. +// Any test that points getDiscussion/getLeaderboard at a fixture directory will +// therefore read the real tree instead, find nothing, and get null back rather +// than an error, so assertions about invalid data pass without validating +// anything. Validate fixtures with the exported schemas below, not the getters. const ADVENTURES_DIR = resolve(process.cwd(), "src/data/adventures"); // --- Zod schemas (source of truth for these types) --- @@ -36,7 +43,16 @@ const solverSchema = z.object({ solvedAt: z.string(), }); -const discussionSchema = z.object({ +// Exported so callers can validate a path of their own choosing: this schema and +// leaderboardSchema below are the file-shape contract, independent of where the +// file sits. Used by scripts/validate-refreshed-data.mjs and by its tests. +// +// Reach for these rather than getDiscussion/getLeaderboard whenever the path is +// not the real tree. See the TESTING TRAP note on ADVENTURES_DIR above: the +// getters bind their directory at import time, so against a fixture they report +// "file absent" instead of validating, and a test written on them passes +// vacuously. That is not hypothetical; it happened while adding the CI validator. +export const discussionSchema = z.object({ discussionUrl: z.string(), discussionPosts: z.array(discussionPostSchema), totalReplies: z.number(), @@ -55,7 +71,9 @@ const leaderboardRowSchema = z.object({ singlePoints: z.number().optional(), }); -const leaderboardSchema = z.object({ +// Exported for the same reason as discussionSchema: validating a path that is +// not the real adventures tree. See the TESTING TRAP note on ADVENTURES_DIR. +export const leaderboardSchema = z.object({ updatedAt: z.string(), rows: z.array(leaderboardRowSchema), }); @@ -81,6 +99,10 @@ function readJson(path: string, schema: z.ZodType): T | null { return result.data; } +// Both getters read the real adventures tree only: their directory was bound at +// import time (see ADVENTURES_DIR). They cannot be redirected at a fixture, and +// against one they return null rather than erroring. Tests validating arbitrary +// files want discussionSchema / leaderboardSchema instead. export function getDiscussion(adventureId: string, levelId: string): Discussion | null { return readJson( resolve(ADVENTURES_DIR, adventureId, `${levelId}-posts.json`), diff --git a/src/lib/community-leaders.ts b/src/lib/community-leaders.ts index b4e32b339..66052c46d 100644 --- a/src/lib/community-leaders.ts +++ b/src/lib/community-leaders.ts @@ -13,6 +13,7 @@ export const SECTION_IDS = [ "challenge-rockstars", "challenge-grand-builders", "challenge-builders", + "adventure-designers", "most-liked", "most-replies", "most-supportive", @@ -22,7 +23,11 @@ export type SectionId = (typeof SECTION_IDS)[number]; const leaderUserSchema = z.object({ username: z.string(), - avatarUrl: z.string(), + // Optional, not loose: buildAvatarUrl in scripts/refresh-community-leaders.mjs + // returns undefined if it ever fails to construct an https URL, and JSON.stringify + // drops the key, so a row legitimately arrives without one. Validated as a URL when + // present, because the value goes straight into an . + avatarUrl: z.url().optional(), count: z.number(), }); @@ -41,6 +46,38 @@ export type LeaderUser = z.infer; export type LeaderSection = z.infer; export type CommunityLeadersData = z.infer; +// The types above describe community-leaders.json, where `username` is always a +// Discourse handle. The types below describe what the leaderboard *renders*, +// which mixes two sources: Discourse-fetched sections keyed by handle, and +// adventure-derived sections keyed by a contributor's display name from YAML. +// Keeping them in separate fields means a handle can never be rendered as a +// name, or a name looked up as a handle. + +export type LeaderRow = { + /** Text shown beside the avatar. A Discourse handle or a real name. */ + displayName: string; + /** Discourse handle, when one is known. Only ever used to resolve an avatar. */ + discourseUsername?: string; + avatarUrl?: string; + count: number; +}; + +export type LeaderRowSection = { + id: SectionId; + title: string; + rows: LeaderRow[]; +}; + +/** A Discourse-sourced row: the handle is both the display text and the lookup key. */ +export function rowFromDiscourse(user: LeaderUser): LeaderRow { + return { + displayName: user.username, + discourseUsername: user.username, + ...(user.avatarUrl ? { avatarUrl: user.avatarUrl } : {}), + count: user.count, + }; +} + export function parseCommunityLeadersData(raw: unknown): CommunityLeadersData { const result = communityLeadersDataSchema.safeParse(raw); if (!result.success) { diff --git a/src/lib/lucide-icons.ts b/src/lib/lucide-icons.ts index 9f4edabff..4588f1a8c 100644 --- a/src/lib/lucide-icons.ts +++ b/src/lib/lucide-icons.ts @@ -27,6 +27,7 @@ import IconHandHeart from "~icons/lucide/hand-heart"; import IconHeart from "~icons/lucide/heart"; import IconLaptop from "~icons/lucide/laptop"; import IconLayers from "~icons/lucide/layers"; +import IconLightbulb from "~icons/lucide/lightbulb"; import IconMail from "~icons/lucide/mail"; import IconMegaphone from "~icons/lucide/megaphone"; import IconMessageCircle from "~icons/lucide/message-circle"; @@ -80,6 +81,7 @@ const _LUCIDE_ICONS_MAP = { heart: IconHeart, laptop: IconLaptop, layers: IconLayers, + lightbulb: IconLightbulb, mail: IconMail, megaphone: IconMegaphone, "message-circle": IconMessageCircle, diff --git a/src/pages/about.astro b/src/pages/about.astro index 20116eb3d..713ab9114 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -68,7 +68,7 @@ const VALUE_ITEMS = [
- About + about

The Home for Open Source Enthusiasts @@ -236,7 +236,7 @@ const VALUE_ITEMS = [ {/* Community Leaders sidebar (lg+ only, sticky) */}

diff --git a/src/pages/accessibility.astro b/src/pages/accessibility.astro index bdcd321e9..4822b263e 100644 --- a/src/pages/accessibility.astro +++ b/src/pages/accessibility.astro @@ -70,7 +70,7 @@ const link = "docs-ext-link";
  • Screen reader announcement of links that open in a new tab.
  • - Color contrast verified at 7:1 for body text and 4.5:1 for large text (WCAGWeb Content Accessibility Guidelines AAAtriple-A conformance level Enhanced Contrast), and 3:1 for UIuser interface controls, in both modes. + Color contrast meets WCAGWeb Content Accessibility Guidelines 2.2 Level AAdouble-A conformance level in both modes: 4.5:1 for body text, 3:1 for large text and UIuser interface controls. This is checked automatically on every pull request. Body text comfortably exceeds the requirement, typically above 11:1. Some secondary text, small overline labels and syntax-highlighted code comments sit between 5:1 and 7:1, so they clear AAdouble-A conformance level but not the stricter AAAtriple-A conformance level Enhanced Contrast threshold.
  • Every link is unambiguous without surrounding context. Card links and call-to-action @@ -171,6 +171,7 @@ const link = "docs-ext-link";
  • axe-core via Playwright on every pull request and on a weekly scheduled scan (Mondays). Runs in dark mode, light mode, and with{" "} + forced-colors: active for Windows High Contrast Mode, all with{" "} prefers-reduced-motion: reduce against the production build. The PRpull request{" "} preview workflow blocks on these scans.
  • diff --git a/src/pages/adventures/[id].astro b/src/pages/adventures/[id].astro index 1da762093..77701d6fa 100644 --- a/src/pages/adventures/[id].astro +++ b/src/pages/adventures/[id].astro @@ -6,7 +6,7 @@ import Layout from "@/layouts/Layout.astro"; import Breadcrumb from "@/components/Breadcrumb.astro"; import StructuredData from "@/components/StructuredData.astro"; import AdventureIcon from "@/components/AdventureIcon.astro"; -import ContributorBadge from "@/components/ContributorBadge.astro"; +import ContributorPill from "@/components/ContributorPill.astro"; import DifficultyBadge from "@/components/DifficultyBadge.astro"; import LivePill from "@/components/LivePill.astro"; import TagChips from "@/components/TagChips.astro"; @@ -21,6 +21,7 @@ import { stripLinks } from "@/lib/markdown"; import { isDeadlinePast } from "@/lib/utils"; import { getLeaderboard } from "@/lib/community-data"; import { isAdventureLive } from "@/lib/challenges"; +import { adventurePillCredit, levelBuildersOf, sortDifficulties } from "@/lib/adventure-credit"; export async function getStaticPaths() { const adventures = await getCollection("adventures"); @@ -44,6 +45,12 @@ const path = `/adventures/${adventure.slug}/`; // One trail, rendered visually by and as BreadcrumbList JSON-LD by // , so the two can never disagree. const crumbs = [{ label: "Adventures", href: "/adventures/" }, { label: adventure.title }]; + +// Whoever built a challenge here, the designer included when the per-level +// fallback credits them. A designer who built nothing is not a builder and is +// not listed: they are already credited in the title pill. +const builders = levelBuildersOf(adventure); +const credit = adventurePillCredit(adventure); ---
    - {adventure.contributor && ( - - )} + {credit && } {adventure.month}
    @@ -129,16 +130,22 @@ const crumbs = [{ label: "Adventures", href: "/adventures/" }, { label: adventur

    {level.name}

    + {descHtml && (

    )} - + {/* No builder credit here: the aside on this page already + lists every challenge builder, so per-card attribution + would say the same thing three times over. */} +

    + +
    ); })} @@ -147,7 +154,7 @@ const crumbs = [{ label: "Adventures", href: "/adventures/" }, { label: adventur

    {u.name}

    - Coming Soon + coming soon
    ))} @@ -176,13 +183,28 @@ const crumbs = [{ label: "Adventures", href: "/adventures/" }, { label: adventur )} - {adventure.contributor && ( + {builders.length > 0 && (
    -

    Adventure by

    - - {adventure.contributor.aboutHtml && ( - - )} +

    + {builders.length === 1 ? "challenge builder" : "challenge builders"} +

    +
    + {builders.map((person) => ( +
    + + {person.difficulties.length > 0 && ( +
    + {sortDifficulties(person.difficulties).map((diff) => ( + + ))} +
    + )} + {person.aboutHtml && ( + + )} +
    + ))} +
    )} diff --git a/src/pages/adventures/[id]/levels/[levelId].astro b/src/pages/adventures/[id]/levels/[levelId].astro index 343698a73..01bcec06e 100644 --- a/src/pages/adventures/[id]/levels/[levelId].astro +++ b/src/pages/adventures/[id]/levels/[levelId].astro @@ -467,6 +467,7 @@ const crumbs = [ levelId={level.id} discussionUrl={level.discussionUrl} contributor={adventure.contributor} + levelContributor={level.contributor} discussion={discussion} leaderboardRows={leaderboard?.rows ?? []} /> diff --git a/src/pages/adventures/[id]/levels/[levelId]/solution.astro b/src/pages/adventures/[id]/levels/[levelId]/solution.astro index 1d95eeb9d..2c4f8fcab 100644 --- a/src/pages/adventures/[id]/levels/[levelId]/solution.astro +++ b/src/pages/adventures/[id]/levels/[levelId]/solution.astro @@ -9,7 +9,7 @@ import IconExternalLink from '~icons/lucide/external-link'; import Layout from "@/layouts/Layout.astro"; import Breadcrumb from "@/components/Breadcrumb.astro"; import StructuredData from "@/components/StructuredData.astro"; -import ContributorBadge from "@/components/ContributorBadge.astro"; +import ContributorPill from "@/components/ContributorPill.astro"; import DifficultyBadge from "@/components/DifficultyBadge.astro"; import SolutionBlocks from "@/components/SolutionBlocks.astro"; import SolutionStepNav from "@/components/SolutionStepNav.astro"; @@ -122,7 +122,7 @@ const crumbs = [
    - Solution + solution

    {solution.title} @@ -132,10 +132,8 @@ const crumbs = [ )} {solution.contributor && (
    -
    )} @@ -193,7 +191,7 @@ const crumbs = [ )} {/* Step cards */} - {!solution.context &&

    Solution Steps

    } +

    Solution Steps

      {solution.steps.map((step, index) => (
    1. @@ -228,7 +226,7 @@ const crumbs = [ {step.takeaways && step.takeaways.length > 0 && (

      - Key Takeaways + key takeaways

        {step.takeaways.map((item) => ( @@ -254,7 +252,7 @@ const crumbs = [

        - Final Result + final result

        {solution.completeSolution.title ?? "Complete Solution"} diff --git a/src/pages/adventures/index.astro b/src/pages/adventures/index.astro index cda3d8661..f971be73b 100644 --- a/src/pages/adventures/index.astro +++ b/src/pages/adventures/index.astro @@ -13,9 +13,9 @@ import { BRAND_NAME } from "@/lib/site"; const adventures = sortAdventuresByMonthDesc((await getCollection("adventures")).map((a) => a.data)); const adventureCount = adventures.length; -const ArrowRightIcon = LUCIDE_ICONS["arrow-right"]; +const ARROW_RIGHT_ICON = LUCIDE_ICONS["arrow-right"]; -const rawHowItWorks = [ +const HOW_IT_WORKS = [ { icon: "book-open", title: "Pick a Scenario", @@ -31,9 +31,7 @@ const rawHowItWorks = [ title: "Apply, Fork, and Build", desc: "Complete real-world scenarios, bring the knowledge into your own projects, fork the challenge repo, and share your solutions with the community.", }, -]; - -const HOW_IT_WORKS = rawHowItWorks.map((item) => ({ ...item, IconComponent: LUCIDE_ICONS[item.icon] })); +].map((item) => ({ ...item, IconComponent: LUCIDE_ICONS[item.icon] })); --- ({ ...item, IconComponent: LUCI

    Filter challenges by technology - {ArrowRightIcon &&
    @@ -93,7 +91,7 @@ const HOW_IT_WORKS = rawHowItWorks.map((item) => ({ ...item, IconComponent: LUCI diff --git a/src/pages/brand.astro b/src/pages/brand.astro index 1538920e4..0cd5db7bd 100644 --- a/src/pages/brand.astro +++ b/src/pages/brand.astro @@ -149,7 +149,7 @@ hsl(var(--foreground))`;
    - Brand + brand

    Brand Guidelines @@ -172,7 +172,7 @@ hsl(var(--foreground))`; {/* Mission and Values */}
    - Foundation + foundation

    Mission and Values

    @@ -194,7 +194,7 @@ hsl(var(--foreground))`; {/* Logo */}

    - Identity + identity

    Logo

    @@ -300,7 +300,7 @@ hsl(var(--foreground))`; {/* Colors */}
    - Identity + identity

    Colors

    @@ -314,7 +314,7 @@ hsl(var(--foreground))`; {/* Dark mode panel: hardcoded dark surface tokens, always renders dark */}

    - Dark Mode + dark mode
    {DARK_COLORS.map((swatch) => ( @@ -335,7 +335,7 @@ hsl(var(--foreground))`; {/* Light mode panel: hardcoded light surface tokens, always renders light */}
    - Light Mode + light mode
    {LIGHT_COLORS.map((swatch) => ( @@ -366,7 +366,7 @@ hsl(var(--foreground))`; {/* Typography */}
    - Identity + identity

    Typography

    @@ -424,7 +424,7 @@ hsl(var(--foreground))`; {/* Design Elements */}

    - Visual Identity + visual identity

    Design Elements

    @@ -520,7 +520,7 @@ hsl(var(--foreground))`; {/* Photography */}
    - Visual Identity + visual identity

    Photography

    @@ -569,7 +569,7 @@ hsl(var(--foreground))`; {/* Voice and Tone */}

    - Communication + communication

    Voice and Tone

    @@ -577,7 +577,7 @@ hsl(var(--foreground))`;

    Brand Name

    -

    Usage

    +

    usage