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 + } }, []); ```