From be6503daf6e9f9adabed56e8d8ce342a8bd0f3a0 Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli Date: Sat, 6 Jun 2026 19:12:01 +0200 Subject: [PATCH 1/3] fix(theme): revert prefers-color-scheme detection; dark is unconditional default - Remove OS-level prefers-color-scheme detection from useTheme.tsx and the inline themeScript in root.tsx - New visitors with no stored preference always get dark mode; light mode is only restored when localStorage explicitly holds "light" - Fixes PageSpeed Insights auditing in light mode (headless Chrome reports prefers-color-scheme: light, so the removed branch was switching it) - Update styleguide.md to document dark as the unconditional default Signed-off-by: Sinduri Guntupalli --- src/hooks/useTheme.tsx | 2 -- src/root.tsx | 2 +- styleguide.md | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/hooks/useTheme.tsx b/src/hooks/useTheme.tsx index f16fc983..f4d800b4 100644 --- a/src/hooks/useTheme.tsx +++ b/src/hooks/useTheme.tsx @@ -23,8 +23,6 @@ export const ThemeProvider = ({ children }: { children: React.ReactNode }): JSX. 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..1edd635f 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 diff --git a/styleguide.md b/styleguide.md index cb108193..116794f6 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. From 97b2d22aac6bb30f4e3a46118254940bc1c3472c Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli Date: Sat, 6 Jun 2026 19:14:26 +0200 Subject: [PATCH 2/3] refactor(theme): remove unreachable typeof window guard in useIsomorphicLayoutEffect The guard was dead code: in SSR useIsomorphicLayoutEffect resolves to useEffect, and React never runs effect callbacks during SSR, so the body was never reached on the server. In the browser window is always defined so the check always evaluated to false. Update styleguide.md to document the correct pattern (try/catch for fallible browser APIs) and explain why no typeof window guard is needed. Signed-off-by: Sinduri Guntupalli --- src/hooks/useTheme.tsx | 1 - styleguide.md | 9 ++++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/hooks/useTheme.tsx b/src/hooks/useTheme.tsx index f4d800b4..da033a31 100644 --- a/src/hooks/useTheme.tsx +++ b/src/hooks/useTheme.tsx @@ -18,7 +18,6 @@ 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") { diff --git a/styleguide.md b/styleguide.md index 116794f6..b6a8d132 100644 --- a/styleguide.md +++ b/styleguide.md @@ -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 + } }, []); ``` From 4c5ae8aa07029178d9b57bb77e706701e8158738 Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli Date: Sat, 6 Jun 2026 19:27:15 +0200 Subject: [PATCH 3/3] docs: fix outdated and inaccurate code comments - Correct refresh cadence from "daily" to "hourly" in useDiscussionPosts and useAdventureLeaderboard (both refreshed by the same hourly workflow) - Extend useDiscussionPosts @returns to include solvers and loaded fields - Fix WalkthroughStep.content description: pre-rendered HTML, not raw markdown - Update TopPlayer and AdventureLevel.solvedCount/topPlayers comments: CommunitySidebar already uses real hook data; these fields are unused - Replace misleading Tailwind content-scanner rationale in MarkdownContent with the accurate reason (module-level constants, created once) - Remove false "kept in sync" claim in root.tsx; JSON-LD description and BRAND_SHORT_DESCRIPTION are intentionally separate - Add matchMedia regression tests to useTheme.test.tsx Signed-off-by: Sinduri Guntupalli --- src/components/MarkdownContent.tsx | 4 ++-- src/data/adventures/types.ts | 8 ++++---- src/hooks/useAdventureLeaderboard.ts | 2 +- src/hooks/useDiscussionPosts.ts | 4 ++-- src/root.tsx | 5 +++-- src/test/useTheme.test.tsx | 18 ++++++++++++++++++ 6 files changed, 30 insertions(+), 11 deletions(-) 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/root.tsx b/src/root.tsx index 1edd635f..85a25cf5 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -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'); + }); });