Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/MarkdownContent.tsx
Original file line number Diff line number Diff line change
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
const CHECK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg>`;

Expand Down
8 changes: 4 additions & 4 deletions src/data/adventures/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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[];
}
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useAdventureLeaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useDiscussionPosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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,
Expand Down
3 changes: 0 additions & 3 deletions src/hooks/useTheme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,10 @@ export const ThemeProvider = ({ children }: { children: React.ReactNode }): JSX.
const [theme, setTheme] = useState<Theme>("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
Expand Down
7 changes: 4 additions & 3 deletions src/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions src/test/useTheme.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
11 changes: 7 additions & 4 deletions styleguide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
}
}, []);
```

Expand Down
Loading