From 1ff58f2307944e399e1e6c8a123b36f85a267527 Mon Sep 17 00:00:00 2001
From: Sinduri Guntupalli
Date: Wed, 27 May 2026 14:16:43 +0200
Subject: [PATCH 1/4] fix: align all workflow actions with org allowlist
- Pin actions/checkout and actions/setup-node to @v4 across all six
workflow files (deploy, preview, new-adventure, new-level,
refresh-community-data, validate-adventures)
- Replace peter-evans/create-pull-request@v6 in new-adventure and
new-level with native git + gh CLI steps
- Update README.md to remove the peter-evans reference
- Add a GitHub Actions allowlist section to CLAUDE.md so future AI
reviews catch violations before they reach CI
Signed-off-by: Sinduri Guntupalli
---
.github/workflows/deploy.yml | 4 +-
.github/workflows/new-adventure.yml | 70 ++++++++++---------
.github/workflows/new-level.yml | 71 ++++++++++----------
.github/workflows/preview.yml | 8 +--
.github/workflows/refresh-community-data.yml | 4 +-
.github/workflows/validate-adventures.yml | 4 +-
CLAUDE.md | 19 ++++++
README.md | 2 +-
8 files changed, 106 insertions(+), 76 deletions(-)
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index c98b3502..28c36469 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -19,9 +19,9 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
diff --git a/.github/workflows/new-adventure.yml b/.github/workflows/new-adventure.yml
index 722b1c43..283b05be 100644
--- a/.github/workflows/new-adventure.yml
+++ b/.github/workflows/new-adventure.yml
@@ -31,9 +31,9 @@ jobs:
contents: write
pull-requests: write
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
@@ -65,32 +65,40 @@ jobs:
--levels "$ADVENTURE_LEVELS"
- name: Create Pull Request
- uses: peter-evans/create-pull-request@v6
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- commit-message: "feat: scaffold adventure ${{ inputs.id }}"
- title: "feat: scaffold adventure — ${{ inputs.title }}"
- body: |
- ## New Adventure Scaffolded
-
- | Field | Value |
- |---|---|
- | ID | `${{ inputs.id }}` |
- | Title | ${{ inputs.title }} |
- | Month | ${{ inputs.month }} |
- | Levels | ${{ inputs.levels }} |
-
- ### Generated files
- - `src/data/adventures/${{ inputs.id }}/adventure.yaml` (fill in TODOs)
- - Discussion JSON stubs per level
- - Routes added to `react-router.config.ts`
- - Sitemap entries added
-
- ### Next steps
- 1. Fill in the TODOs in `src/data/adventures/${{ inputs.id }}/adventure.yaml`
- 2. Update `discussionUrl` in the YAML and each level's `-posts.json` file
- 3. Run `npm run generate` to produce the TypeScript from YAML
- 4. Run `node scripts/refresh-discussions.mjs`
- 5. Run all checks: `npm run lint && npm test && npm run build && npm run test:e2e`
- branch: "feat/adventure-${{ inputs.id }}"
- delete-branch: true
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ ADVENTURE_ID: ${{ inputs.id }}
+ ADVENTURE_TITLE: ${{ inputs.title }}
+ ADVENTURE_MONTH: ${{ inputs.month }}
+ ADVENTURE_LEVELS: ${{ inputs.levels }}
+ run: |
+ BRANCH="feat/adventure-${ADVENTURE_ID}"
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git checkout -b "$BRANCH"
+ git add -A
+ git commit -m "feat: scaffold adventure ${ADVENTURE_ID}"
+ git push origin "$BRANCH"
+ {
+ printf '## New Adventure Scaffolded\n\n'
+ printf '| Field | Value |\n|---|---|\n'
+ printf '| ID | `%s` |\n' "$ADVENTURE_ID"
+ printf '| Title | %s |\n' "$ADVENTURE_TITLE"
+ printf '| Month | %s |\n' "$ADVENTURE_MONTH"
+ printf '| Levels | %s |\n\n' "$ADVENTURE_LEVELS"
+ printf '### Generated files\n'
+ printf -- '- `src/data/adventures/%s/adventure.yaml` (fill in TODOs)\n' "$ADVENTURE_ID"
+ printf -- '- Discussion JSON stubs per level\n'
+ printf -- '- Routes added to `react-router.config.ts`\n'
+ printf -- '- Sitemap entries added\n\n'
+ printf '### Next steps\n'
+ printf '1. Fill in the TODOs in `src/data/adventures/%s/adventure.yaml`\n' "$ADVENTURE_ID"
+ printf '2. Update `discussionUrl` in the YAML and each level `-posts.json` file\n'
+ printf '3. Run `npm run generate` to produce the TypeScript from YAML\n'
+ printf '4. Run `node scripts/refresh-discussions.mjs`\n'
+ printf '5. Run all checks: `npm run lint && npm test && npm run build && npm run test:e2e`\n'
+ } > /tmp/pr-body.md
+ gh pr create \
+ --title "feat: scaffold adventure — ${ADVENTURE_TITLE}" \
+ --body-file /tmp/pr-body.md \
+ --head "$BRANCH"
diff --git a/.github/workflows/new-level.yml b/.github/workflows/new-level.yml
index b78ea515..b749e6ea 100644
--- a/.github/workflows/new-level.yml
+++ b/.github/workflows/new-level.yml
@@ -26,9 +26,9 @@ jobs:
contents: write
pull-requests: write
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
@@ -59,35 +59,38 @@ jobs:
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Create Pull Request
- uses: peter-evans/create-pull-request@v6
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- commit-message: "feat: add ${{ inputs.level }} level to ${{ inputs.adventure }}"
- title: "feat: add ${{ inputs.level }} level — ${{ inputs.adventure }}"
- body: |
- ## New Level Added
-
- | Field | Value |
- |---|---|
- | Adventure | `${{ inputs.adventure }}` |
- | Level | `${{ inputs.level }}` |
-
- ### Generated
- - `src/data/adventures/${{ inputs.adventure }}/${{ inputs.level }}-posts.json` (discussion stub)
- - Prerender entry in `react-router.config.ts`
- - Sitemap entry in `public/sitemap.xml`
-
- ### Manual step required
- Paste this into the `levels` array in `src/data/adventures/${{ inputs.adventure }}/adventure.yaml`:
-
- ```yaml
- ${{ steps.scaffold.outputs.snippet }}
- ```
-
- ### Next steps
- 1. Paste the snippet above and fill in the TODOs
- 2. Update `discussionUrl` in the YAML and the `-posts.json` file
- 3. Run `node scripts/refresh-discussions.mjs`
- 4. Run all checks: `npm run lint && npm test && npm run build && npm run test:e2e`
- branch: "feat/${{ inputs.adventure }}-${{ inputs.level }}"
- delete-branch: true
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ ADVENTURE: ${{ inputs.adventure }}
+ LEVEL: ${{ inputs.level }}
+ SNIPPET: ${{ steps.scaffold.outputs.snippet }}
+ run: |
+ BRANCH="feat/${ADVENTURE}-${LEVEL}"
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git checkout -b "$BRANCH"
+ git add -A
+ git commit -m "feat: add ${LEVEL} level to ${ADVENTURE}"
+ git push origin "$BRANCH"
+ {
+ printf '## New Level Added\n\n'
+ printf '| Field | Value |\n|---|---|\n'
+ printf '| Adventure | `%s` |\n' "$ADVENTURE"
+ printf '| Level | `%s` |\n\n' "$LEVEL"
+ printf '### Generated\n'
+ printf -- '- `src/data/adventures/%s/%s-posts.json` (discussion stub)\n' "$ADVENTURE" "$LEVEL"
+ printf -- '- Prerender entry in `react-router.config.ts`\n'
+ printf -- '- Sitemap entry in `public/sitemap.xml`\n\n'
+ printf '### Manual step required\n'
+ printf 'Paste this into the `levels` array in `src/data/adventures/%s/adventure.yaml`:\n\n' "$ADVENTURE"
+ printf '```yaml\n%s\n```\n\n' "$SNIPPET"
+ printf '### Next steps\n'
+ printf '1. Paste the snippet above and fill in the TODOs\n'
+ printf '2. Update `discussionUrl` in the YAML and the `-posts.json` file\n'
+ printf '3. Run `node scripts/refresh-discussions.mjs`\n'
+ printf '4. Run all checks: `npm run lint && npm test && npm run build && npm run test:e2e`\n'
+ } > /tmp/pr-body.md
+ gh pr create \
+ --title "feat: add ${LEVEL} level — ${ADVENTURE}" \
+ --body-file /tmp/pr-body.md \
+ --head "$BRANCH"
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
index 00e9ce99..be467a8e 100644
--- a/.github/workflows/preview.yml
+++ b/.github/workflows/preview.yml
@@ -16,9 +16,9 @@ jobs:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
@@ -42,9 +42,9 @@ jobs:
if: always() && (needs.smoke.result == 'success' || needs.smoke.result == 'skipped')
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
diff --git a/.github/workflows/refresh-community-data.yml b/.github/workflows/refresh-community-data.yml
index 503cc0df..209040b6 100644
--- a/.github/workflows/refresh-community-data.yml
+++ b/.github/workflows/refresh-community-data.yml
@@ -21,9 +21,9 @@ jobs:
refresh:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
diff --git a/.github/workflows/validate-adventures.yml b/.github/workflows/validate-adventures.yml
index 32641832..d836a931 100644
--- a/.github/workflows/validate-adventures.yml
+++ b/.github/workflows/validate-adventures.yml
@@ -18,9 +18,9 @@ jobs:
validate:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
diff --git a/CLAUDE.md b/CLAUDE.md
index 1ec516d4..258c8d7d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -646,6 +646,25 @@ Complete checklist for every new adventure:
- Only static files in `dist/client/` are deployed. No server config is needed.
- The base path is set via the `VITE_BASE_PATH` environment variable (defaults to `/`). PR previews set this automatically in `preview.yml`. Never change this without verifying GitHub Pages routing.
+### GitHub Actions allowlist
+
+The `off-on-dev` organisation restricts which third-party actions can run. Only the following are permitted:
+
+| Action | Pinned version |
+|---|---|
+| `actions/checkout` | `@v4` — never `@v5`, `@v6`, or any other version |
+| `actions/setup-node` | `@v4` — never `@v5`, `@v6`, or any other version |
+| `JamesIves/github-pages-deploy-action` | any tag (`@*`) |
+| `marocchino/sticky-pull-request-comment` | any tag (`@*`) |
+| `rossjrw/pr-preview-action` | any tag (`@*`) |
+| Actions owned by `off-on-dev` | any |
+| Actions created by GitHub | any |
+| Actions verified in the GitHub Marketplace | any |
+
+**Before adding any new `uses:` line to a workflow file, verify the action is on this list.** If it is not, replace it with an equivalent using `gh` (GitHub CLI, pre-installed on all `ubuntu-latest` runners) or native shell commands. Do not ask for the allowlist to be expanded unless there is no alternative.
+
+This rule is enforced at the org level. A workflow that references an action outside the allowlist will fail immediately with an "action is not allowed" error — it will never even reach the step that uses it.
+
---
## Before Submitting Code
diff --git a/README.md b/README.md
index b80a85d9..9f2cfe27 100644
--- a/README.md
+++ b/README.md
@@ -197,7 +197,7 @@ Go to the repository's **Actions** tab, select the workflow, click **Run workflo
| **New Adventure** | `id`, `title`, `month`, `levels` (comma-separated) | Scaffolds a full adventure: TS file with TODOs, discussion JSON stubs, patches `react-router.config.ts` and `sitemap.xml`, opens a PR |
| **New Level** | `adventure` (existing ID), `level` (beginner/intermediate/expert) | Adds a level to an existing adventure: discussion JSON stub, patches config and sitemap, opens a PR with a TS snippet to paste |
-Both workflows create a branch and open a PR automatically via `peter-evans/create-pull-request`. The PR description includes next steps (fill in content TODOs, run verification, etc.).
+Both workflows commit the scaffolded files to a new branch and open a PR automatically using the GitHub CLI (`gh pr create`). The PR description includes next steps (fill in content TODOs, run verification, etc.).
### Via local scripts
From b8c32a48594b6856aab161cdb881360f6c6f37f8 Mon Sep 17 00:00:00 2001
From: Sinduri Guntupalli
Date: Wed, 27 May 2026 14:33:45 +0200
Subject: [PATCH 2/4] refactor: cleanup dead code and extract shared components
- Delete LinkSection (exported but never imported anywhere)
- Remove export from BRAND_SECONDARY_LINE_WORD (internal only)
- Extract TagChips component; replace 3 identical inline chip maps
in AdventureDetail, ChallengeDetail (structured + legacy layouts)
- Extract CodespacesButton component; replace 2 identical button
blocks (mobile CTA and desktop sidebar) in ChallengeDetail
- Simplify StructuredLayout to accept (adventure, level) directly;
remove 10 pass-through variable declarations in ChallengeDetail
Signed-off-by: Sinduri Guntupalli
---
src/components/CodespacesButton.tsx | 24 +++++++
src/components/LinkSection.tsx | 27 --------
src/components/TagChips.tsx | 21 ++++++
src/data/constants.ts | 2 +-
src/pages/AdventureDetail.tsx | 13 +---
src/pages/ChallengeDetail.tsx | 104 +++-------------------------
6 files changed, 60 insertions(+), 131 deletions(-)
create mode 100644 src/components/CodespacesButton.tsx
delete mode 100644 src/components/LinkSection.tsx
create mode 100644 src/components/TagChips.tsx
diff --git a/src/components/CodespacesButton.tsx b/src/components/CodespacesButton.tsx
new file mode 100644
index 00000000..f9af4d48
--- /dev/null
+++ b/src/components/CodespacesButton.tsx
@@ -0,0 +1,24 @@
+import { type JSX } from "react";
+import { ExternalLink } from "lucide-react";
+
+type CodespacesButtonProps = {
+ href: string;
+ fullWidth?: boolean;
+};
+
+export const CodespacesButton = ({ href, fullWidth = false }: CodespacesButtonProps): JSX.Element => (
+ <>
+
+ Open in Codespaces
+ (opens in new tab)
+
+
+ Free GitHub account required
+
+ >
+);
diff --git a/src/components/LinkSection.tsx b/src/components/LinkSection.tsx
deleted file mode 100644
index c73104b4..00000000
--- a/src/components/LinkSection.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { JSX } from "react";
-import { ExternalLink } from "lucide-react";
-
-type LinkItem = { label: string; href: string };
-
-type LinkSectionProps = {
- heading: string;
- links: LinkItem[];
-};
-
-const extLinkCls = "docs-ext-link";
-
-export const LinkSection = ({ heading, links }: LinkSectionProps): JSX.Element => (
-
-);
diff --git a/src/components/TagChips.tsx b/src/components/TagChips.tsx
new file mode 100644
index 00000000..c2240102
--- /dev/null
+++ b/src/components/TagChips.tsx
@@ -0,0 +1,21 @@
+import { type JSX } from "react";
+import { Link } from "react-router";
+import { tagToSlug } from "@/data/adventures";
+
+type TagChipsProps = {
+ tags: readonly string[];
+};
+
+export const TagChips = ({ tags }: TagChipsProps): JSX.Element => (
+ <>
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+ >
+);
diff --git a/src/data/constants.ts b/src/data/constants.ts
index e435193d..1e2fd9da 100644
--- a/src/data/constants.ts
+++ b/src/data/constants.ts
@@ -30,7 +30,7 @@ export const CONTACT_EMAIL = "offondev@gmail.com";
export const BRAND_SLOGAN_PARTS = ["Vendor-Neutral", "Open Source", "Community-Driven"] as const;
export const BRAND_SLOGAN = BRAND_SLOGAN_PARTS.join(". ");
-export const BRAND_SECONDARY_LINE_WORD = "always";
+const BRAND_SECONDARY_LINE_WORD = "always";
export const BRAND_SECONDARY_LINE_PARTS = [
`${BRAND_SECONDARY_LINE_WORD} On.`,
`${BRAND_SECONDARY_LINE_WORD} Open.`,
diff --git a/src/pages/AdventureDetail.tsx b/src/pages/AdventureDetail.tsx
index cd18a713..97dba86d 100644
--- a/src/pages/AdventureDetail.tsx
+++ b/src/pages/AdventureDetail.tsx
@@ -2,7 +2,7 @@ import { type JSX } from "react";
import { useParams, Link } from "react-router";
import type { MetaFunction } from "react-router";
import { ArrowRight } from "lucide-react";
-import { ADVENTURES, type AdventureLevel, tagToSlug } from "@/data/adventures";
+import { ADVENTURES, type AdventureLevel } from "@/data/adventures";
import { NotFoundPage } from "@/components/NotFoundPage";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/components/Footer";
@@ -13,6 +13,7 @@ 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";
@@ -97,15 +98,7 @@ const AdventureDetail = (): JSX.Element => {
{adventure.contributor && (
)}
- {adventure.tags.map((tag) => (
-
- {tag}
-
- ))}
+
{adventure.story}
diff --git a/src/pages/ChallengeDetail.tsx b/src/pages/ChallengeDetail.tsx
index 29b6bfbb..cc9ffec4 100644
--- a/src/pages/ChallengeDetail.tsx
+++ b/src/pages/ChallengeDetail.tsx
@@ -2,7 +2,9 @@ import { type JSX } from "react";
import { useParams, Link } from "react-router";
import type { MetaFunction } from "react-router";
import { ArrowLeft, Check, ExternalLink } from "lucide-react";
-import { ADVENTURES, tagToSlug } from "@/data/adventures";
+import { ADVENTURES } from "@/data/adventures";
+import { TagChips } from "@/components/TagChips";
+import { CodespacesButton } from "@/components/CodespacesButton";
import { DifficultyBadge } from "@/components/DifficultyBadge";
import { ContributorBadge } from "@/components/ContributorBadge";
import { NotFoundPage } from "@/components/NotFoundPage";
@@ -56,32 +58,10 @@ export const meta: MetaFunction = ({ params }) => {
type StructuredLayoutProps = {
adventure: (typeof ADVENTURES)[number];
level: (typeof ADVENTURES)[number]["levels"][number];
- intro: string[] | undefined;
- objective: string[] | undefined;
- toolbox: { name: string; url?: string; description?: string }[] | undefined;
- backstory: string[] | undefined;
- architecture: string[] | undefined;
- architectureDiagram: string | undefined;
- diagramAlt: string | undefined;
- howToPlay: { title: string; body: string }[] | undefined;
- helpfulLinks: { label: string; url: string }[] | undefined;
- verification: (typeof ADVENTURES)[number]["levels"][number]["verification"];
};
-const StructuredLayout = ({
- adventure,
- level,
- intro,
- objective,
- toolbox,
- backstory,
- architecture,
- architectureDiagram,
- diagramAlt,
- howToPlay,
- helpfulLinks,
- verification,
-}: StructuredLayoutProps): JSX.Element => {
+const StructuredLayout = ({ adventure, level }: StructuredLayoutProps): JSX.Element => {
+ const { intro, objective, toolbox, backstory, architecture, architectureDiagram, diagramAlt, howToPlay, helpfulLinks, verification } = level;
return (
<>
{/* Header */}
@@ -93,15 +73,7 @@ const StructuredLayout = ({
- {(level.topics ?? adventure.tags).map((tag) => (
-
- {tag}
-
- ))}
+
{/* Intro as hook */}
@@ -181,18 +153,7 @@ const StructuredLayout = ({
@@ -326,18 +287,7 @@ const StructuredLayout = ({
{/* CTA panel - hidden on mobile where inline CTA is shown */}
{
return ;
}
- const intro = level.intro;
- const backstory = level.backstory;
- const architecture = level.architecture;
- const architectureDiagram = level.architectureDiagram;
- const diagramAlt = level.diagramAlt;
- const objective = level.objective;
- const toolbox = level.toolbox;
- const howToPlay = level.howToPlay;
- const helpfulLinks = level.helpfulLinks;
- const verification = level.verification;
-
const hasStructuredContent = !!(
- intro || backstory || architecture || toolbox || howToPlay || helpfulLinks
+ level.intro || level.backstory || level.architecture || level.toolbox || level.howToPlay || level.helpfulLinks
);
return (
@@ -401,20 +340,7 @@ const ChallengeDetail = (): JSX.Element => {
{hasStructuredContent ? (
-
+
) : (
<>
{/* Legacy layout for levels without structured content */}
@@ -423,15 +349,7 @@ const ChallengeDetail = (): JSX.Element => {
{adventure.title}
- {(level.topics ?? adventure.tags).map((tag) => (
-
- {tag}
-
- ))}
+
{adventure.contributor && (
From e4e79c59152dcd09170cc0652b0002d8e5c7b12d Mon Sep 17 00:00:00 2001
From: Sinduri Guntupalli
Date: Wed, 27 May 2026 15:34:25 +0200
Subject: [PATCH 3/4] perf: split adventure summaries bundle and add font
preloads
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Generate summaries.ts from YAML (id, title, month, story, tags, contributor name, level id/name/difficulty/topics/learnings only) — no imports from the full *.generated.ts files, so bundlers can keep it in its own chunk separate from walkthrough steps, toolbox items, architecture diagrams, and other detail-page fields
- Add AdventureLevelSummary, AdventureCardSummary, and RelatedLevelSummary types to types.ts; re-export them from index.ts
- Switch AdventureCard, FilteredLevelCard, and ChallengesGrid to import from summaries.ts; pages and components that need full adventure data continue to import from index.ts
- Add font preloads to root.tsx via the links() export (inter-latin-400/500, syne-latin-700); font-display: optional requires preloads to keep fonts visible on throttled connections
- Add vendor-react manual chunk to vite.config.ts to isolate React/ReactDOM/scheduler into a long-lived cacheable chunk
- Remove btn-primary idle pulse glow (ctaGlow keyframes) and btn-ghost hover glow; remove bg-primary/10 hover fill from btn-ghost; clean up corresponding light mode overrides
- Add fetchPriority="low" to the non-LCP light-mode logo in Navbar
- Update tests, styleguide.md, and CLAUDE.md to document the summaries.ts pattern
Signed-off-by: Sinduri Guntupalli
---
CLAUDE.md | 5 +-
scripts/generate-adventures.mjs | 74 ++++++++++-
src/components/AdventureCard.tsx | 4 +-
src/components/ChallengesGrid.tsx | 8 +-
src/components/FilteredLevelCard.tsx | 4 +-
src/components/Navbar.tsx | 2 +-
src/data/adventures/index.ts | 2 +-
src/data/adventures/summaries.ts | 188 +++++++++++++++++++++++++++
src/data/adventures/types.ts | 31 +++++
src/index.css | 39 +-----
src/root.tsx | 12 ++
src/test/root.test.tsx | 13 +-
styleguide.md | 6 +-
vite.config.ts | 15 +++
14 files changed, 343 insertions(+), 60 deletions(-)
create mode 100644 src/data/adventures/summaries.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 258c8d7d..28316eb3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -228,7 +228,8 @@ without exception. They exist to prevent debugging by accumulation.
- Static content lives in `src/data/` as typed TypeScript objects/arrays.
- No runtime `fetch` calls in components. All network data must be fetched at build time.
-- **Adventure content pipeline:** Adventure data is authored as YAML files at `src/data/adventures//adventure.yaml` and compiled to TypeScript via `scripts/generate-adventures.mjs`. The generated files (`*.generated.ts` and `index.ts`) are committed to the repo. The `prebuild` hook runs the generator automatically before every build. Never edit `*.generated.ts` or `src/data/adventures/index.ts` by hand.
+- **Adventure content pipeline:** Adventure data is authored as YAML files at `src/data/adventures//adventure.yaml` and compiled to TypeScript via `scripts/generate-adventures.mjs`. The generated files (`*.generated.ts`, `index.ts`, and `summaries.ts`) are committed to the repo. The `prebuild` hook runs the generator automatically before every build. Never edit `*.generated.ts`, `src/data/adventures/index.ts`, or `src/data/adventures/summaries.ts` by hand.
+ - **`summaries.ts` vs `index.ts`:** `summaries.ts` is a lightweight snapshot (id, title, month, story, tags, contributor name, and per-level id/name/difficulty/topics/learnings) with no imports from the full `*.generated.ts` files. Components that only render cards or tag filters (e.g. `ChallengesGrid`, `AdventureCard`, `FilteredLevelCard`) must import from `@/data/adventures/summaries` to avoid pulling the full detail-page data into the home page bundle. Detail pages and components that need full adventure content import from `@/data/adventures`.
- **Why YAML + generated TS instead of writing TS directly?** YAML is easier to author and review for non-engineers, and validated by JSON Schema. Vite cannot import YAML natively, so a generator converts it to fully-typed TS that the app can statically import. Committing the generated files means the build works without running the generator first, and CI can detect when generated output is out of sync with the source YAML.
- **Schema validation:** Adventure YAML files are validated against `schemas/adventure.schema.json` (JSON Schema Draft 2020-12). Run `npm run generate:validate` to check without writing files.
- **Build-time fetching:** Discussion data lives in per-level JSON files under `src/data/adventures//-posts.json`. Each file contains only `discussionUrl`, `discussionPosts`, and `totalReplies`. These are refreshed hourly by the GitHub Action in `.github/workflows/refresh-community-data.yml` (runs `scripts/refresh-discussions.mjs`). Components import the JSON dynamically via `import.meta.glob`.
@@ -733,7 +734,7 @@ State the result of each check explicitly before finishing a task.
- Do not change the `@theme` block in `src/index.css` without verifying the change does not break existing components.
- Do not reinstall `@radix-ui/*` packages that were removed. If a Radix primitive is genuinely needed, check whether raw HTML with Tailwind solves the problem first.
- Do not re-derive data from `ADVENTURES` inside component files. Any computed value that belongs to the data layer (e.g. a deduplicated tag list) should be exported from `src/data/adventures/index.ts` and imported. `ALL_TAGS` is the established pattern.
-- Do not edit `*.generated.ts` or `src/data/adventures/index.ts` by hand. These are produced by `scripts/generate-adventures.mjs` from the YAML source files. Edit the YAML and run `npm run generate` instead.
+- Do not edit `*.generated.ts`, `src/data/adventures/index.ts`, or `src/data/adventures/summaries.ts` by hand. These are produced by `scripts/generate-adventures.mjs` from the YAML source files. Edit the YAML and run `npm run generate` instead.
---
diff --git a/scripts/generate-adventures.mjs b/scripts/generate-adventures.mjs
index 890a1096..a9e51a28 100644
--- a/scripts/generate-adventures.mjs
+++ b/scripts/generate-adventures.mjs
@@ -333,6 +333,70 @@ function generateAdventureTs(data) {
return lines.join("\n");
}
+/**
+ * Generate summaries.ts — a lightweight card-only snapshot of all adventure data.
+ * This file has NO imports from the full *.generated.ts files, so bundlers can
+ * split it into a separate chunk. Pages that only render AdventureCard and
+ * FilteredLevelCard import from here instead of from index.ts, saving ~12 KB
+ * gzipped on the home page by not pulling in walkthrough steps, toolbox items,
+ * architecture sections, and other detail-page fields.
+ */
+function generateSummariesTs(adventures) {
+ const lines = [];
+ lines.push(`// Generated by scripts/generate-adventures.mjs — do not edit by hand.`);
+ lines.push(`import type { AdventureCardSummary, RelatedLevelSummary } from "./types";`);
+ lines.push(``);
+ lines.push(`export const ADVENTURE_SUMMARIES: AdventureCardSummary[] = [`);
+
+ for (const data of adventures) {
+ lines.push(` {`);
+ lines.push(` id: "${data.id}",`);
+ lines.push(` title: "${escapeDoubleQuoted(data.title)}",`);
+ lines.push(` month: "${data.month}",`);
+ lines.push(` story: ${formatString(data.story)},`);
+ lines.push(` tags: [${data.tags.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`);
+ if (data.contributor) {
+ lines.push(` contributor: { name: "${escapeDoubleQuoted(data.contributor.name)}" },`);
+ }
+ lines.push(` levels: [`);
+ for (const level of data.levels) {
+ lines.push(` {`);
+ lines.push(` id: "${level.id}",`);
+ lines.push(` name: "${escapeDoubleQuoted(level.name)}",`);
+ lines.push(` difficulty: "${level.difficulty}",`);
+ if (level.topics && level.topics.length > 0) {
+ lines.push(` topics: [${level.topics.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`);
+ }
+ lines.push(` learnings: ${formatStringArray(level.learnings, " ")},`);
+ lines.push(` },`);
+ }
+ lines.push(` ],`);
+ lines.push(` },`);
+ }
+
+ lines.push(`];`);
+ lines.push(``);
+ lines.push(`/** All unique technology tags across all adventures, for card and filter views. */`);
+ lines.push(`export const SUMMARY_TAGS: string[] = Array.from(`);
+ lines.push(` new Set(ADVENTURE_SUMMARIES.flatMap((a) => a.tags))`);
+ lines.push(`).sort();`);
+ lines.push(``);
+ lines.push(`/** Returns level summaries matching a tag, for filtered card views on the home page. */`);
+ lines.push(`export const getLevelSummariesByTag = (tag: string): RelatedLevelSummary[] =>`);
+ lines.push(` ADVENTURE_SUMMARIES`);
+ lines.push(` .filter((a) => a.tags.includes(tag))`);
+ lines.push(` .flatMap((a) =>`);
+ lines.push(` a.levels.map((level) => ({`);
+ lines.push(` level,`);
+ lines.push(` adventureId: a.id,`);
+ lines.push(` adventureTitle: a.title,`);
+ lines.push(` }))`);
+ lines.push(` );`);
+ lines.push(``);
+
+ return lines.join("\n");
+}
+
function generateIndexTs(adventures) {
const lines = [];
@@ -343,7 +407,7 @@ function generateIndexTs(adventures) {
}
lines.push(`import type { Adventure, AdventureContributor, RelatedLevel } from "./types";`);
lines.push(``);
- lines.push(`export type { Adventure, AdventureLevel, AdventureContributor, RelatedLevel, ToolboxItem, WalkthroughStep, VerificationInfo, TopPlayer, UpcomingLevel } from "./types";`);
+ lines.push(`export type { Adventure, AdventureLevel, AdventureContributor, RelatedLevel, ToolboxItem, WalkthroughStep, VerificationInfo, TopPlayer, UpcomingLevel, AdventureLevelSummary, AdventureCardSummary, RelatedLevelSummary } from "./types";`);
lines.push(``);
lines.push(`export const ADVENTURES: Adventure[] = [`);
for (const adv of adventures) {
@@ -460,7 +524,13 @@ function main() {
writeFileSync(indexPath, indexContent);
console.log(` Generated: src/data/adventures/index.ts`);
- console.log(`\n\x1b[32mDone!\x1b[0m Generated ${adventures.length} adventure file(s) + index.ts`);
+ // Generate summaries.ts (lightweight card-only data, no imports from full generated files)
+ const summariesContent = generateSummariesTs(adventures);
+ const summariesPath = resolve(ADVENTURES_DIR, "summaries.ts");
+ writeFileSync(summariesPath, summariesContent);
+ console.log(` Generated: src/data/adventures/summaries.ts`);
+
+ console.log(`\n\x1b[32mDone!\x1b[0m Generated ${adventures.length} adventure file(s) + index.ts + summaries.ts`);
}
main();
diff --git a/src/components/AdventureCard.tsx b/src/components/AdventureCard.tsx
index 7ef2eb19..2ed78173 100644
--- a/src/components/AdventureCard.tsx
+++ b/src/components/AdventureCard.tsx
@@ -1,11 +1,11 @@
import type { JSX } from "react";
import { Link } from "react-router";
import { Layers } from "lucide-react";
-import type { Adventure } from "@/data/adventures";
+import type { AdventureCardSummary } from "@/data/adventures";
import { DifficultyBadge } from "@/components/DifficultyBadge";
import { ContributorBadge } from "@/components/ContributorBadge";
-type AdventureCardProps = { adventure: Adventure };
+type AdventureCardProps = { adventure: AdventureCardSummary };
export const AdventureCard = ({ adventure }: AdventureCardProps): JSX.Element => (
{
const [activeTopic, setActiveTopic] = useState(null);
- const filteredLevels = activeTopic ? getLevelsByTag(activeTopic) : [];
+ const filteredLevels = activeTopic ? getLevelSummariesByTag(activeTopic) : [];
return (
@@ -20,7 +20,7 @@ export const ChallengesGrid = (): JSX.Element => {
{/* Topic filter chips */}
- {ALL_TAGS.map((tag) => (
+ {SUMMARY_TAGS.map((tag) => (
{
) : (
/* Adventure cards */
- {ADVENTURES.map((adventure) => (
+ {ADVENTURE_SUMMARIES.map((adventure) => (
))}
diff --git a/src/components/FilteredLevelCard.tsx b/src/components/FilteredLevelCard.tsx
index 874d05ff..51beb7f8 100644
--- a/src/components/FilteredLevelCard.tsx
+++ b/src/components/FilteredLevelCard.tsx
@@ -1,11 +1,11 @@
import type { JSX } from "react";
import { Link } from "react-router";
import { cn } from "@/lib/utils";
-import type { AdventureLevel } from "@/data/adventures";
+import type { AdventureLevelSummary } from "@/data/adventures";
import { DifficultyBadge } from "@/components/DifficultyBadge";
type FilteredLevelCardProps = {
- level: AdventureLevel;
+ level: AdventureLevelSummary;
adventureId: string;
adventureTitle: string;
className?: string;
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index 06b8c110..9fdcaa4a 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -90,7 +90,7 @@ export const Navbar = (): JSX.Element => {
{/* Both always in DOM so React Router preloads both; CSS controls visibility. */}
-
+
{/* Desktop nav */}
diff --git a/src/data/adventures/index.ts b/src/data/adventures/index.ts
index 28b42cc0..0ea8bd1e 100644
--- a/src/data/adventures/index.ts
+++ b/src/data/adventures/index.ts
@@ -4,7 +4,7 @@ import { ECHOES_LOST_IN_ORBIT } from "./echoes-lost-in-orbit.generated";
import { THE_AI_OBSERVATORY } from "./the-ai-observatory.generated";
import type { Adventure, AdventureContributor, RelatedLevel } from "./types";
-export type { Adventure, AdventureLevel, AdventureContributor, RelatedLevel, ToolboxItem, WalkthroughStep, VerificationInfo, TopPlayer, UpcomingLevel } from "./types";
+export type { Adventure, AdventureLevel, AdventureContributor, RelatedLevel, ToolboxItem, WalkthroughStep, VerificationInfo, TopPlayer, UpcomingLevel, AdventureLevelSummary, AdventureCardSummary, RelatedLevelSummary } from "./types";
export const ADVENTURES: Adventure[] = [
BLIND_BY_DESIGN,
diff --git a/src/data/adventures/summaries.ts b/src/data/adventures/summaries.ts
new file mode 100644
index 00000000..dbc39424
--- /dev/null
+++ b/src/data/adventures/summaries.ts
@@ -0,0 +1,188 @@
+// Generated by scripts/generate-adventures.mjs — do not edit by hand.
+import type { AdventureCardSummary, RelatedLevelSummary } from "./types";
+
+export const ADVENTURE_SUMMARIES: AdventureCardSummary[] = [
+ {
+ id: "blind-by-design",
+ title: "Blind by Design",
+ month: "MAY 2026",
+ story: "Three levels of OpenFeature with flagd as the provider, in a Java + Spring Boot service. Wire the SDK against a flagd sidecar (Beginner), layer evaluation context to target by cohort (Intermediate), then instrument flag evaluations with OpenTelemetry and roll back a misbehaving fractional rollout (Expert). All without redeploying.",
+ tags: ["OpenFeature", "flagd", "Spring Boot", "Java", "OpenTelemetry", "Grafana"],
+ contributor: { name: "Simon Schrottner" },
+ levels: [
+ {
+ id: "beginner",
+ name: "Stand up the Lab",
+ difficulty: "Beginner",
+ topics: ["OpenFeature", "flagd", "Spring Boot"],
+ learnings: [
+ "How an OpenFeature client and provider work together: the SDK is provider-agnostic and the flagd provider plugs in via dependency only",
+ "What remote provider means in practice: the SDK calls a separate flag service (flagd) over gRPC, not parsing flags.json itself",
+ "What flags.json looks like for flagd (state, variants, defaultVariant)",
+ "Why hot-reload of the flag file matters operationally: configuration without redeploy",
+ ],
+ },
+ {
+ id: "intermediate",
+ name: "Outcome by Cohort",
+ difficulty: "Intermediate",
+ topics: ["OpenFeature", "flagd", "Spring Boot", "Java"],
+ learnings: [
+ "How evaluation context works in OpenFeature: passing runtime attributes (user ID, cohort, region) to influence flag resolution",
+ "How to configure flagd targeting rules to route specific cohorts to specific flag variants without code changes",
+ "Why cohort-based rollouts reduce blast radius: only the targeted segment sees the new behaviour",
+ "How to verify targeting is working correctly by inspecting flag evaluation results per context",
+ ],
+ },
+ ],
+ },
+ {
+ id: "building-cloudhaven",
+ title: "Building CloudHaven",
+ month: "JAN 2026",
+ story: "Join the Infrastructure Guild and modernize CloudHaven's infrastructure from manual provisioning to a self-service platform using Infrastructure as Code. A hands-on journey through infrastructure as code with OpenTofu and GitHub Actions.",
+ tags: ["OpenTofu", "Terraform", "GitHub Actions", "Trivy", "TDD"],
+ contributor: { name: "Katharina Sick" },
+ levels: [
+ {
+ id: "beginner",
+ name: "The Foundation Stones",
+ difficulty: "Beginner",
+ topics: ["OpenTofu"],
+ learnings: [
+ "Infrastructure as Code with OpenTofu",
+ "Remote state management with GCS backend",
+ "Dynamic resource provisioning with for_each",
+ "Conditional resources with the enabled meta-argument, new in OpenTofu",
+ ],
+ },
+ {
+ id: "intermediate",
+ name: "The Modular Metropolis",
+ difficulty: "Intermediate",
+ topics: ["OpenTofu", "TDD"],
+ learnings: [
+ "OpenTofu module testing with tofu test",
+ "Test-Driven Development (TDD) workflow",
+ "Input validation with custom rules",
+ "Refactoring infrastructure safely with moved blocks",
+ ],
+ },
+ {
+ id: "expert",
+ name: "The Guardian Protocols",
+ difficulty: "Expert",
+ topics: ["OpenTofu", "GitHub Actions", "Trivy"],
+ learnings: [
+ "GitHub Actions for drift detection and plan/apply",
+ "Integration tests with service containers",
+ "Security scanning with Trivy",
+ ],
+ },
+ ],
+ },
+ {
+ id: "echoes-lost-in-orbit",
+ title: "Echoes Lost in Orbit",
+ month: "DEC 2025",
+ story: "Restore interstellar communications by fixing broken GitOps setups, progressive delivery systems, and observability pipelines across three galactic missions.",
+ tags: ["Argo CD", "Argo Rollouts", "OpenTelemetry", "Jaeger", "PromQL"],
+ contributor: { name: "Katharina Sick" },
+ levels: [
+ {
+ id: "beginner",
+ name: "Broken Echoes",
+ difficulty: "Beginner",
+ topics: ["Argo CD"],
+ learnings: [
+ "Debug GitOps flows with Argo CD",
+ "ApplicationSet templating & pitfalls",
+ "Environment isolation & namespaces",
+ "Sync policies: automated, prune & self-heal",
+ ],
+ },
+ {
+ id: "intermediate",
+ name: "The Silent Canary",
+ difficulty: "Intermediate",
+ topics: ["Argo Rollouts", "PromQL"],
+ learnings: [
+ "Progressive delivery with Argo Rollouts",
+ "Canary deployments & automated analysis",
+ "Write PromQL queries for health validation",
+ "Kube-state-metrics for deployment decisions",
+ ],
+ },
+ {
+ id: "expert",
+ name: "Hyperspace Operations & Transport",
+ difficulty: "Expert",
+ topics: ["Argo Rollouts", "OpenTelemetry", "Jaeger", "PromQL"],
+ learnings: [
+ "Configure OpenTelemetry Collector pipelines",
+ "Spanmetrics connector (traces to metrics)",
+ "Detect idle canaries with traffic validation",
+ "Distributed tracing with Jaeger",
+ ],
+ },
+ ],
+ },
+ {
+ id: "the-ai-observatory",
+ title: "The AI Observatory",
+ month: "FEB 2026",
+ story: "Investigate a mysterious bandwidth anomaly at a remote research station by instrumenting its AI system with OpenTelemetry, OpenLLMetry, and Jaeger.",
+ tags: ["OpenTelemetry", "OpenLLMetry", "Jaeger", "Prometheus", "Python"],
+ contributor: { name: "Katharina Sick" },
+ levels: [
+ {
+ id: "beginner",
+ name: "Calibrating the Lens",
+ difficulty: "Beginner",
+ topics: ["OpenTelemetry", "OpenLLMetry", "Jaeger"],
+ learnings: [
+ "Instrument Python AI apps with OpenLLMetry",
+ "Analyze traces in Jaeger",
+ ],
+ },
+ {
+ id: "intermediate",
+ name: "The Distracted Pilot",
+ difficulty: "Intermediate",
+ topics: ["OpenTelemetry", "OpenLLMetry", "Jaeger", "Prometheus"],
+ learnings: [
+ "Instrument RAG pipelines with OpenLLMetry",
+ "Create custom OpenTelemetry metrics in Python",
+ "Write PromQL queries & recording rules in Prometheus",
+ ],
+ },
+ {
+ id: "expert",
+ name: "The Noise Filter",
+ difficulty: "Expert",
+ topics: ["OpenTelemetry", "OpenLLMetry", "Jaeger"],
+ learnings: [
+ "OpenTelemetry GenAI semantic conventions",
+ "Tail sampling in the OTel Collector",
+ ],
+ },
+ ],
+ },
+];
+
+/** All unique technology tags across all adventures, for card and filter views. */
+export const SUMMARY_TAGS: string[] = Array.from(
+ new Set(ADVENTURE_SUMMARIES.flatMap((a) => a.tags))
+).sort();
+
+/** Returns level summaries matching a tag, for filtered card views on the home page. */
+export const getLevelSummariesByTag = (tag: string): RelatedLevelSummary[] =>
+ ADVENTURE_SUMMARIES
+ .filter((a) => a.tags.includes(tag))
+ .flatMap((a) =>
+ a.levels.map((level) => ({
+ level,
+ adventureId: a.id,
+ adventureTitle: a.title,
+ }))
+ );
diff --git a/src/data/adventures/types.ts b/src/data/adventures/types.ts
index 56745961..13857e28 100644
--- a/src/data/adventures/types.ts
+++ b/src/data/adventures/types.ts
@@ -124,3 +124,34 @@ export type RelatedLevel = {
adventureId: string;
adventureTitle: string;
};
+
+/**
+ * Lightweight level shape used for card and filter views on the home/challenges pages.
+ * Contains only the fields needed to render AdventureCard and FilteredLevelCard.
+ * Generated into summaries.ts — do not import the full AdventureLevel where this suffices.
+ */
+export type AdventureLevelSummary = {
+ id: string;
+ name: string;
+ difficulty: "Beginner" | "Intermediate" | "Expert";
+ topics?: string[];
+ learnings: string[];
+};
+
+/** Lightweight adventure shape for card grid views. Generated into summaries.ts. */
+export type AdventureCardSummary = {
+ id: string;
+ title: string;
+ month: string;
+ story: string;
+ tags: string[];
+ levels: AdventureLevelSummary[];
+ contributor?: { name: string };
+};
+
+/** A level summary with its parent adventure context, for filtered card views. */
+export type RelatedLevelSummary = {
+ level: AdventureLevelSummary;
+ adventureId: string;
+ adventureTitle: string;
+};
diff --git a/src/index.css b/src/index.css
index 34a99537..e64d4a16 100644
--- a/src/index.css
+++ b/src/index.css
@@ -583,9 +583,6 @@ html {
.animate-marquee {
animation: none;
}
- .btn-primary {
- animation: none;
- }
/* Firefly particles run a continuous 9-keyframe animation with positional
transforms. Vestibular disorder and motion-sensitive users are affected
by persistent movement even from decorative elements. */
@@ -639,7 +636,7 @@ html {
/* Ghost/outline button */
.btn-ghost {
- @apply inline-flex items-center gap-2 rounded-md border border-foreground/35 bg-transparent px-5 py-3 text-sm font-semibold text-foreground transition-all duration-200 hover:bg-primary/10 hover:border-primary/60 hover:text-primary active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
+ @apply inline-flex items-center gap-2 rounded-md border border-foreground/35 bg-transparent px-5 py-3 text-sm font-semibold text-foreground transition-all duration-200 hover:border-primary/60 hover:text-primary active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
}
/* Soft tinted button */
@@ -680,26 +677,6 @@ html {
/* ─── Electric glow effects ──────────────────────────────── */
-@keyframes ctaGlow {
- 0%, 100% { box-shadow: none; }
- 50% { box-shadow: 0 0 18px 4px hsl(var(--primary) / 0.35); }
-}
-
-/* Button primary: idle pulse glow to draw the eye */
-.btn-primary {
- animation: ctaGlow 3s ease-in-out infinite;
-}
-
-/* Button primary: electric glow on hover/focus — stops the idle pulse */
-.btn-primary:hover,
-.btn-primary:focus-visible {
- animation: none;
- box-shadow:
- 0 0 28px -2px hsl(var(--primary) / 0.8),
- 0 0 60px -8px hsl(var(--primary) / 0.4),
- 0 2px 8px -2px hsl(0 0% 0% / 0.3);
-}
-
/* Tag pill: electric glow on hover */
.pill-inactive:hover {
box-shadow:
@@ -707,12 +684,6 @@ html {
0 0 30px -8px hsl(var(--primary) / 0.25);
}
-/* Ghost button: electric glow on hover */
-.btn-ghost:hover {
- box-shadow:
- 0 0 20px -4px hsl(var(--primary) / 0.45),
- 0 0 40px -8px hsl(var(--primary) / 0.2);
-}
/* Glowing card hover - add card-glow class to cards */
.card-glow {
@@ -786,14 +757,6 @@ html {
color: hsl(var(--foreground-hover));
}
-.light .btn-primary {
- box-shadow: none;
-}
-
-.light .btn-primary:hover {
- box-shadow: none;
-}
-
.light .pill-active {
color: hsl(var(--foreground-hover));
background-color: hsl(43 100% 50% / 0.15);
diff --git a/src/root.tsx b/src/root.tsx
index 0bfa59c1..84e33424 100644
--- a/src/root.tsx
+++ b/src/root.tsx
@@ -1,7 +1,19 @@
import type { JSX } from "react";
+import type { LinksFunction } from "react-router";
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import "./index.css";
+export const links: LinksFunction = () => [
+ // Preload fonts used above the fold on every page.
+ // Inter 400 + 500: body text and Navbar. Syne 700: all h1–h6 via the @layer base rule in index.css.
+ // Latin-only subsets are always needed for English content and never generate "preloaded but not used" warnings.
+ // font-display: optional requires preloads to succeed — without them, the optional window expires
+ // on throttled connections before fonts are discovered, so the browser falls back to system fonts permanently.
+ { rel: "preload", href: `${import.meta.env.BASE_URL}fonts/inter-latin-400-normal.woff2`, as: "font", type: "font/woff2", crossOrigin: "anonymous" },
+ { rel: "preload", href: `${import.meta.env.BASE_URL}fonts/inter-latin-500-normal.woff2`, as: "font", type: "font/woff2", crossOrigin: "anonymous" },
+ { rel: "preload", href: `${import.meta.env.BASE_URL}fonts/syne-latin-700-normal.woff2`, as: "font", type: "font/woff2", crossOrigin: "anonymous" },
+];
+
// Inline script strings extracted to constants so tests can assert ordering in this file.
const themeScript = `(function(){var t=localStorage.getItem("theme");if(t==="light"){document.documentElement.classList.remove("dark");document.documentElement.classList.add("light");}})();`;
diff --git a/src/test/root.test.tsx b/src/test/root.test.tsx
index 0a7569b6..227dfb46 100644
--- a/src/test/root.test.tsx
+++ b/src/test/root.test.tsx
@@ -54,11 +54,14 @@ describe("root.tsx - gated-load Consent Mode v2 bootstrap", () => {
expect(source).not.toContain("GA_COOKIE_DOMAIN");
});
- it("keeps the JSON-LD blocks and does not re-add font preloads removed in simplification", () => {
+ it("keeps the JSON-LD blocks and includes critical font preloads via the links() export", () => {
expect(source).toContain("application/ld+json");
- // Font preloads were removed to eliminate "preloaded but not used" warnings.
- // font-display: swap handles FOUT without needing manual preload hints.
- expect(source).not.toContain("inter-latin-400-normal.woff2");
- expect(source).not.toContain("syne-latin-700-normal.woff2");
+ // font-display: optional requires preloads to work correctly. Without them the optional
+ // window expires on throttled connections (Lighthouse uses 4G) before fonts are discovered,
+ // causing the browser to use system fonts permanently. Only the Latin subset is preloaded —
+ // always needed for English content and never generates "preloaded but not used" warnings.
+ expect(source).toContain("inter-latin-400-normal.woff2");
+ expect(source).toContain("inter-latin-500-normal.woff2");
+ expect(source).toContain("syne-latin-700-normal.woff2");
});
});
diff --git a/styleguide.md b/styleguide.md
index 415f1049..9c608a4c 100644
--- a/styleguide.md
+++ b/styleguide.md
@@ -203,8 +203,8 @@ In light mode, `bg-primary` sections (PageHero, BottomCTA) stay amber. Do **not*
| Class | Style | Usage |
|---|---|---|
-| `.btn-primary` | Filled amber, `rounded-md px-5 py-3 text-sm font-semibold`, electric glow on hover | Default CTA on page background |
-| `.btn-ghost` | Outlined, `border-foreground/35`, subtle glow on hover | Secondary CTA on page background |
+| `.btn-primary` | Filled amber, `rounded-md px-5 py-3 text-sm font-semibold`, `brightness-110` on hover | Default CTA on page background |
+| `.btn-ghost` | Outlined, `border-foreground/35`, amber border and text on hover | Secondary CTA on page background |
| `.btn-soft` | Tinted `bg-primary/10 border-primary/30`, no glow | Tertiary / low-emphasis action |
| `.btn-inverse` | White/background fill with primary border, primary text; inverts on hover to primary bg | Primary CTA inside a `bg-primary` section (e.g. `PageHero`, `BottomCTA`) |
| `.btn-ghost-inverse` | Transparent with background-colored border and text; inverts on hover to background fill | Secondary CTA inside a `bg-primary` section |
@@ -636,7 +636,7 @@ Navigation card used in tag-filtered level grids. The entire card is a ` `
| Prop | Type | Default | Description |
|---|---|---|---|
-| `level` | `AdventureLevel` | required | Level data from `src/data/adventures` |
+| `level` | `AdventureLevelSummary` | required | Level data — accepts both `AdventureLevelSummary` (from `summaries.ts`) and full `AdventureLevel` (from `index.ts`), since the summary is a structural subset |
| `adventureId` | `string` | required | Used to build the link href: `/adventures/:id/levels/:levelId` |
| `adventureTitle` | `string` | required | Shown in the card footer as a tag label |
| `className` | `string?` | — | Merged onto the root ` ` via `cn()`. Pass `"animate-fade-up-delay-1"` when the card is in a staggered grid. |
diff --git a/vite.config.ts b/vite.config.ts
index 84f9477d..6e1a61a9 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -16,6 +16,21 @@ export default defineConfig(() => ({
},
},
plugins: [tailwindcss(), reactRouter()],
+ build: {
+ rollupOptions: {
+ output: {
+ manualChunks: (id) => {
+ if (
+ id.includes("/node_modules/react/") ||
+ id.includes("/node_modules/react-dom/") ||
+ id.includes("/node_modules/scheduler/")
+ ) {
+ return "vendor-react";
+ }
+ },
+ },
+ },
+ },
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
From 759afac22ffa98234f07126d693a37653f50925f Mon Sep 17 00:00:00 2001
From: Sinduri Guntupalli
Date: Wed, 27 May 2026 15:44:18 +0200
Subject: [PATCH 4/4] perf(navbar): bump light logo fetchPriority to high
- Light logo is the LCP candidate for light mode users; low priority
was delaying its fetch while the hidden dark logo was fetched first.
- Both SVGs are tiny so fetching both at high priority has no
meaningful performance cost.
Signed-off-by: Sinduri Guntupalli
---
src/components/Navbar.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index 9fdcaa4a..d33efc38 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -90,7 +90,7 @@ export const Navbar = (): JSX.Element => {
{/* Both always in DOM so React Router preloads both; CSS controls visibility. */}
-
+
{/* Desktop nav */}