diff --git a/src/components/MarkdownContent.tsx b/src/components/MarkdownContent.tsx
index f7e2c301..e589aa5f 100644
--- a/src/components/MarkdownContent.tsx
+++ b/src/components/MarkdownContent.tsx
@@ -1,7 +1,7 @@
import { useEffect, useRef, type JSX } from "react";
-// SVG markup for copy button icons. Defined as constants so Tailwind's
-// content scanner detects the class names used in the DOM-injected elements.
+// SVG markup for copy button icons. Defined as module-level constants so the
+// strings are created once rather than on every effect run.
const COPY_SVG = ``;
const CHECK_SVG = ``;
diff --git a/src/data/adventures/types.ts b/src/data/adventures/types.ts
index eb3dd9e3..d595dafb 100644
--- a/src/data/adventures/types.ts
+++ b/src/data/adventures/types.ts
@@ -11,7 +11,7 @@ export type ToolboxItem = {
url?: string;
}
-/** One step in the Walkthrough section. content is rendered as markdown so it can contain code blocks and links. */
+/** One step in the Walkthrough section. content is pre-rendered HTML generated at build time and rendered via dangerouslySetInnerHTML in MarkdownContent. */
export type WalkthroughStep = {
title: string;
content: string;
@@ -30,7 +30,7 @@ export type HelpfulLink = {
description?: string;
}
-/** Mock entry in the "Top players" leaderboard inside the CommunitySidebar. */
+/** A player entry for the top-players leaderboard. Currently defined on AdventureLevel but not consumed by any component. */
export type TopPlayer = {
username: string;
count: number;
@@ -86,8 +86,8 @@ export type AdventureLevel = {
verification: VerificationInfo;
// Optional SEO meta description (max 160 chars). When absent, ChallengeDetail.tsx generates one from level name, intro, and topics.
metaDescription?: string;
- // Mock community stats shown in the CommunitySidebar. Real data will replace
- // these once we aggregate certificate posts and cross-challenge contribution.
+ // Unused fields — real solver and leaderboard data is fetched at runtime by
+ // useDiscussionPosts and useAdventureLeaderboard. No component reads these.
solvedCount?: number;
topPlayers?: TopPlayer[];
}
diff --git a/src/hooks/useAdventureLeaderboard.ts b/src/hooks/useAdventureLeaderboard.ts
index 93979bc5..d4462aef 100644
--- a/src/hooks/useAdventureLeaderboard.ts
+++ b/src/hooks/useAdventureLeaderboard.ts
@@ -38,7 +38,7 @@ export type LeaderboardResult = {
/**
* Loads adventure leaderboard data from the per-adventure leaderboard.json file.
- * Data is refreshed daily by the GitHub Actions workflow.
+ * Data is refreshed hourly by the GitHub Actions workflow.
* @param adventureId - The adventure slug (e.g. "blind-by-design").
* @param loader - Optional loader for testing; defaults to dynamically importing the JSON.
*/
diff --git a/src/hooks/useDiscussionPosts.ts b/src/hooks/useDiscussionPosts.ts
index b8f87fc4..a107bcfc 100644
--- a/src/hooks/useDiscussionPosts.ts
+++ b/src/hooks/useDiscussionPosts.ts
@@ -43,7 +43,7 @@ const discussionModules = import.meta.glob("@/data/adventures/**/*-posts.json");
// Default loader dynamically imports the per-level JSON file.
// Each level JSON is expected to have optional `discussionPosts` and
-// `totalReplies` fields, populated by the daily GitHub Action.
+// `totalReplies` fields, populated by the hourly GitHub Action.
const defaultLoader: DiscussionDataLoader = async (adventureId, levelId) => {
const key = `/src/data/adventures/${adventureId}/${levelId}-posts.json`;
const loader = discussionModules[key];
@@ -65,7 +65,7 @@ export type DiscussionResult = {
* @param adventureId - The adventure slug (e.g. "echoes-lost-in-orbit").
* @param levelId - The level slug (e.g. "beginner").
* @param loader - Optional loader for testing; defaults to dynamically importing the level JSON.
- * @returns Object with posts array and totalReplies count.
+ * @returns Object with posts array, totalReplies count, solvers list, and a loaded flag.
*/
export function useDiscussionPosts(
adventureId: string,
diff --git a/src/hooks/useTheme.tsx b/src/hooks/useTheme.tsx
index f16fc983..da033a31 100644
--- a/src/hooks/useTheme.tsx
+++ b/src/hooks/useTheme.tsx
@@ -18,13 +18,10 @@ export const ThemeProvider = ({ children }: { children: React.ReactNode }): JSX.
const [theme, setTheme] = useState("dark");
useIsomorphicLayoutEffect(() => {
- if (typeof window === "undefined") return;
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored === "light") {
startTransition(() => setTheme("light"));
- } else if (!stored && window.matchMedia?.("(prefers-color-scheme: light)").matches) {
- startTransition(() => setTheme("light"));
}
} catch {
// localStorage unavailable; keep default dark theme
diff --git a/src/root.tsx b/src/root.tsx
index 63198411..85a25cf5 100644
--- a/src/root.tsx
+++ b/src/root.tsx
@@ -17,7 +17,7 @@ export const links: LinksFunction = () => [
];
// Inline script strings extracted to constants so tests can assert ordering in this file.
-const themeScript = `(function(){try{var t=localStorage.getItem("theme");if(t==="light"){document.documentElement.classList.remove("dark");document.documentElement.classList.add("light");}else if(!t&&window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches){document.documentElement.classList.remove("dark");document.documentElement.classList.add("light");}}catch(e){}})();`;
+const themeScript = `(function(){try{var t=localStorage.getItem("theme");if(t==="light"){document.documentElement.classList.remove("dark");document.documentElement.classList.add("light");}}catch(e){}})();`;
// Gated-load Consent Mode v2. The inline bootstrap does the bare minimum and
// nothing more: bootstrap dataLayer, define window.gtag as the push shim, and
@@ -33,9 +33,10 @@ const gtagBootstrap = `window.dataLayer=window.dataLayer||[];function gtag(){dat
// "Inline speculation rules cannot currently be modified after they are processed."
const SPECULATION_RULES = `{"prefetch":[{"source":"document","where":{"href_matches":["/adventures/","/challenges/"]},"eagerness":"moderate"}]}`;
-// Description is kept in sync with the Index page meta description (src/pages/Index.tsx).
+// JSON-LD description is intentionally a longer, schema-oriented variant.
+// Index page uses BRAND_SHORT_DESCRIPTION (src/data/constants.ts) for its meta description.
// JSON-LD inline scripts cannot reference TS constants (they live inside dangerouslySetInnerHTML),
-// so the string is duplicated. Update both together.
+// so this string is maintained separately.
const webSiteJsonLd = `{"@context":"https://schema.org","@type":"WebSite","name":"OffOn","url":"https://offon.dev","description":"A vendor-neutral community for open source enthusiasts. Learn through hands-on challenges, share what you know, and connect with people who love open source."}`;
// sameAs links populate Google's Knowledge Panel. Mirror LINKEDIN_URL,
diff --git a/src/test/useTheme.test.tsx b/src/test/useTheme.test.tsx
index 0b2e576c..dbc17011 100644
--- a/src/test/useTheme.test.tsx
+++ b/src/test/useTheme.test.tsx
@@ -176,4 +176,22 @@ describe('useTheme - file content regressions', () => {
expect(scriptsPos).toBeGreaterThan(-1);
expect(themeScriptPos).toBeLessThan(scriptsPos);
});
+
+ it('inline theme script does not use matchMedia (dark is unconditional default)', () => {
+ const source = readFileSync(
+ resolve(__dirname, '../root.tsx'),
+ 'utf-8'
+ );
+ const scriptStart = source.indexOf('const themeScript =');
+ const scriptEnd = source.indexOf('`;', scriptStart);
+ expect(source.slice(scriptStart, scriptEnd)).not.toContain('matchMedia');
+ });
+
+ it('useTheme hook does not use matchMedia (dark is unconditional default)', () => {
+ const source = readFileSync(
+ resolve(__dirname, '../hooks/useTheme.tsx'),
+ 'utf-8'
+ );
+ expect(source).not.toContain('matchMedia');
+ });
});
diff --git a/styleguide.md b/styleguide.md
index cb108193..b6a8d132 100644
--- a/styleguide.md
+++ b/styleguide.md
@@ -376,7 +376,7 @@ const { theme, toggle } = useTheme();
| `theme` | `"light" \| "dark"` | Current active theme |
| `toggle()` | `() => void` | Toggle between light and dark and persist the choice |
-The default is dark, with one exception: if no preference is stored and `window.matchMedia('(prefers-color-scheme: light)').matches`, the hook initialises to light on first load. User preference (stored in `localStorage`) always takes precedence over the OS-level preference. All light mode color overrides live in `src/index.css` as unlayered CSS rules scoped to `.light`. Never place light mode overrides inside `@layer base`, as they would be silently overridden by `@layer utilities`.
+The default is dark. If no preference is stored, the hook initialises to dark regardless of the OS-level `prefers-color-scheme` setting. User preference (stored in `localStorage` under `THEME_STORAGE_KEY`) always takes precedence. All light mode color overrides live in `src/index.css` as unlayered CSS rules scoped to `.light`. Never place light mode overrides inside `@layer base`, as they would be silently overridden by `@layer utilities`.
Theme changes are announced to screen readers via a `ThemeAnnouncer` component (private, mounted in `Layout.tsx`) that uses `role="status" aria-live="polite"`. The announcer skips the initial mount to avoid announcing the default theme on page load.
@@ -443,12 +443,15 @@ import { useEffect, useLayoutEffect } from "react";
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
```
-Always guard any `localStorage` or browser API access inside the callback:
+No `typeof window` guard is needed inside the callback. In SSR, `useIsomorphicLayoutEffect` resolves to `useEffect`, and React never runs effect callbacks during SSR, so the callback body is never reached on the server. Guard `localStorage` and other fallible browser APIs with `try/catch` instead (for private browsing and quota errors):
```ts
useIsomorphicLayoutEffect(() => {
- if (typeof window === "undefined") return;
- // browser-only code here
+ try {
+ // browser-only code here
+ } catch {
+ // handle unavailable API
+ }
}, []);
```