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..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`.
@@ -646,6 +647,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
@@ -714,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/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
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 */}
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"),