diff --git a/CLAUDE.md b/CLAUDE.md
index b28d21f..b370093 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -53,7 +53,12 @@ unreachable — the site must never 500 because Strapi is down.
The site ships RTL Arabic; physical directions silently break it.
Check: `grep -rnE "\b(pl|pr|ml|mr|left|right)-[0-9]" apps/web/app` → nothing meaningful.
3. **Surfaces, not a theme.** `:root` *is* the dark surface; `data-surface="light"` flips a
- subtree and repaints itself. Do not add a global theme toggle or a `dark:` variant.
+ subtree and repaints itself. Never add a second theme system or a `dark:` variant. **The
+ one exception, accepted deliberately:** the nav's theme toggle flips *which* surface ``
+ stands on (dark default ↔ light), persisted via the `sharper-theme` cookie read in the root
+ loader and set by the root action — SSR-rendered so the first byte and first paint always
+ match. The mechanism stays surfaces-only: the toggle just re-roots them. `--surface-nav`
+ must keep a value in BOTH surface blocks.
4. **Figma is READ ONLY.** Only `get_design_context`, `get_screenshot`, `get_metadata`,
`get_variable_defs`. Never call anything that writes (`use_figma`, `generate_figma_design`,
`create_new_file`, `upload_assets`, shaders, plugins, …). Before the first
diff --git a/apps/web/app/components/ui/Navigation/Navigation.tsx b/apps/web/app/components/ui/Navigation/Navigation.tsx
index 70afbba..a8b37d4 100644
--- a/apps/web/app/components/ui/Navigation/Navigation.tsx
+++ b/apps/web/app/components/ui/Navigation/Navigation.tsx
@@ -10,6 +10,7 @@ import {
import { Brand } from "./Brand";
import { DesktopNav } from "./DesktopNav";
import { MobileNav } from "./MobileNav";
+import { ThemeToggle } from "./ThemeToggle";
import { getDefaultNavItems, getNavigationStrings } from "./nav-items";
import { cx } from "./styles";
import type { NavItem, NavigationStrings, NavSurface } from "./types";
@@ -53,9 +54,10 @@ export interface NavigationProps {
* and the whole tree moves into a full-screen drawer.
*
* EU VISIBILITY: the updated design has no funding badge in the bar - the
- * Actions slot holds the language globe alone. The mandatory "Funded by the
- * European Union" lockup is carried by , which renders on every page,
- * and by the mobile drawer; it is not dropped from the site.
+ * Actions slot holds the language globe and the theme toggle. The mandatory
+ * "Funded by the European Union" lockup is carried by , which
+ * renders on every page, and by the mobile drawer; it is not dropped from the
+ * site.
*/
export function Navigation({
locale = "en",
@@ -148,6 +150,11 @@ export function Navigation({
)}
+ {/* The theme control sits with the other actions at every breakpoint:
+ after the (desktop-only) language cluster on wide screens, beside
+ the hamburger on a compact bar. */}
+
+
. */
+interface RootLoaderData {
+ theme?: AppTheme;
+}
+
+/** A minimal sun, for "switch to light". Decorative - the label calls it. */
+function SunIcon() {
+ return (
+
+ );
+}
+
+/** A minimal crescent moon, for "switch to dark". Decorative. */
+function MoonIcon() {
+ return (
+
+ );
+}
+
+export interface ThemeToggleProps {
+ /** Reading locale, for the accessible label. */
+ locale: Locale;
+ className?: string;
+}
+
+/**
+ * The site's one global theme control: sun in dark mode, moon in light.
+ *
+ * It renders the target of the next click - the label and the icon describe
+ * what a press DOES ("Switch to light theme"), never the current state - and
+ * posts that target to the root action at `/`. The action persists the
+ * `sharper-theme` cookie, the loader re-runs and the shell repaints
+ * `data-surface` on , so the whole tier-2 palette re-resolves. Because
+ * the current theme is read off the root loader (which the server fed from
+ * the cookie), the SSR markup and the first client render always agree.
+ */
+export function ThemeToggle({ locale, className }: ThemeToggleProps) {
+ const fetcher = useFetcher();
+ const rootData = useRouteLoaderData("root");
+ const t = useMemo(() => createTranslator(locale), [locale]);
+
+ const theme: AppTheme = rootData?.theme ?? "dark";
+ const targetTheme: AppTheme = theme === "light" ? "dark" : "light";
+ const label =
+ targetTheme === "light" ? t("nav.themeToLight") : t("nav.themeToDark");
+
+ return (
+
+
+
+
+ );
+}
+
+export default ThemeToggle;
+
+/* ---------------------------------------------------------------------------
+ USAGE
+ ---------------------------------------------------------------------------
+ // In the nav actions cluster, between the language control and the
+ // hamburger. Reads the theme from the root loader on its own.
+
+ ------------------------------------------------------------------------ */
\ No newline at end of file
diff --git a/apps/web/app/i18n/messages/ar.json b/apps/web/app/i18n/messages/ar.json
index 1bfa545..3418d85 100644
--- a/apps/web/app/i18n/messages/ar.json
+++ b/apps/web/app/i18n/messages/ar.json
@@ -17,6 +17,8 @@
"closeMenu": "إغلاق القائمة",
"homeLinkLabel": "الصفحة الرئيسية لمشروع SHARP-ER",
"languageMenu": "اللغة",
+ "themeToLight": "التبديل إلى المظهر الفاتح",
+ "themeToDark": "التبديل إلى المظهر الداكن",
"skipToContent": "تخطَّ إلى المحتوى",
"sections": {
"home": "الرئيسية",
diff --git a/apps/web/app/i18n/messages/en.json b/apps/web/app/i18n/messages/en.json
index ba178a8..375295e 100644
--- a/apps/web/app/i18n/messages/en.json
+++ b/apps/web/app/i18n/messages/en.json
@@ -17,6 +17,8 @@
"closeMenu": "Close menu",
"homeLinkLabel": "SHARP-ER home",
"languageMenu": "Language",
+ "themeToLight": "Switch to light theme",
+ "themeToDark": "Switch to dark theme",
"skipToContent": "Skip to content",
"sections": {
"home": "Home",
diff --git a/apps/web/app/lib/theme.ts b/apps/web/app/lib/theme.ts
new file mode 100644
index 0000000..20e2441
--- /dev/null
+++ b/apps/web/app/lib/theme.ts
@@ -0,0 +1,33 @@
+/* ============================================================================
+ SITE THEME
+ ----------------------------------------------------------------------------
+ The one global theme decision the site makes. The whole tier-2 palette is
+ still per-surface (see app/styles/semantic.css); this simply lets a reader
+ flip WHICH surface the document stands on, `:root` dark by default. The
+ preference travels as the `sharper-theme` cookie so the server renders the
+ same surface the next page will paint - no flash-of-wrong-theme, no
+ hydration mismatch.
+ ========================================================================= */
+
+export type AppTheme = "dark" | "light";
+
+export const THEME_COOKIE = "sharper-theme";
+
+/** Default when no cookie is present. `:root` is the dark surface. */
+export const DEFAULT_THEME: AppTheme = "dark";
+
+/** Parse the cookie header into a theme. Anything unknown resolves to dark. */
+export function readTheme(cookieHeader: string | null): AppTheme {
+ if (!cookieHeader) return DEFAULT_THEME;
+ const pair = cookieHeader
+ .split(";")
+ .map((part) => part.trim())
+ .find((part) => part.startsWith(`${THEME_COOKIE}=`));
+ const value = pair?.split("=")[1];
+ return value === "light" ? "light" : value === "dark" ? "dark" : DEFAULT_THEME;
+}
+
+/** The Set-Cookie value that persists a theme choice for a year. */
+export function themeCookie(theme: AppTheme): string {
+ return `${THEME_COOKIE}=${theme}; Path=/; Max-Age=31536000; SameSite=Lax`;
+}
\ No newline at end of file
diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx
index 2234e37..397ae7d 100644
--- a/apps/web/app/root.tsx
+++ b/apps/web/app/root.tsx
@@ -4,7 +4,9 @@
`Layout` renders the whole document, which is why and
are correct in the FIRST SSR byte: the locale is read from the URL, not from
a cookie, a context provider or an effect, so the server and the first
- client render cannot disagree.
+ client render cannot disagree. The theme surface is the one deliberate
+ exception to "no cookies" - it is a cookie read in the loader, but because
+ the loader is what the server renders, the first byte still matches.
The header and the footer live here rather than in the ":lang" layout so the
ErrorBoundary keeps its chrome: a 404 is a normal page of the site, with the
@@ -43,6 +45,7 @@ import {
} from "~/lib/route-handle";
import { alternateUrls, buildMeta, canonicalUrl } from "~/lib/seo";
import { buildFooterLinkGroups, buildNavItems } from "~/lib/site-navigation";
+import { DEFAULT_THEME, readTheme, themeCookie, type AppTheme } from "~/lib/theme";
/**
* Last-resort origin for the canonical and hreflang links. The root loader
@@ -80,9 +83,31 @@ export function loader({ request }: Route.LoaderArgs) {
dir: dirFor(locale),
pathname,
siteUrl: getSiteUrl(request),
+ /* Which surface the document stands on. Read from the theme cookie so the
+ server paints the same surface the reader asked for - the browser never
+ guesses, so there is no flash-of-wrong-theme and no hydration mismatch.
+ `:root` is dark, so the absence of a cookie is a dark first visit. */
+ theme: readTheme(request.headers.get("Cookie")),
};
}
+/**
+ * The root action is the theme toggle's destination (the nav posts here at
+ * `/`). It validates the requested theme and persists it with Set-Cookie; the
+ * loader re-runs and the shell repaints the `data-surface` on .
+ */
+export async function action({ request }: Route.ActionArgs) {
+ const formData = await request.formData();
+ const requested = formData.get("theme");
+ const theme: AppTheme =
+ requested === "light" ? "light" : requested === "dark" ? "dark" : DEFAULT_THEME;
+
+ return new Response(null, {
+ status: 204,
+ headers: { "Set-Cookie": themeCookie(theme) },
+ });
+}
+
function pathnameLocale(pathname: string): Locale {
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
return first === "ar" ? "ar" : first === "en" ? "en" : DEFAULT_LOCALE;
@@ -126,13 +151,19 @@ export function Layout({ children }: { children: ReactNode }) {
const navItems = useMemo(() => buildNavItems(locale), [locale]);
const footerLinkGroups = useMemo(() => buildFooterLinkGroups(locale), [locale]);
- const navSurface = useRouteHandleValue(readNavSurface) ?? "dark";
- const footerSurface = useRouteHandleValue(readFooterSurface) ?? "dark";
+ /* The theme cookie decides which surface the DOCUMENT stands on. The header
+ and footer follow it, unless the route's handle forces one (`navSurface`
+ / `footerSurface`) - so a page can still draw a light nav on a dark
+ surface in either theme. */
+ const rootData = useRouteLoaderData("root");
+ const theme: AppTheme = rootData?.theme ?? DEFAULT_THEME;
+
+ const navSurface = useRouteHandleValue(readNavSurface) ?? theme;
+ const footerSurface = useRouteHandleValue(readFooterSurface) ?? theme;
/* Canonical and hreflang are emitted HERE, not from a route's `meta`: a leaf
`meta` replaces its parents' wholesale, so a page that writes its own
would silently drop them. `handle.canonicalPath` is the escape hatch. */
- const rootData = useRouteLoaderData("root");
const siteUrl = rootData?.siteUrl ?? FALLBACK_SITE_URL;
const canonicalPath = useRouteHandleValue(readCanonicalPath) ?? pathname;
/* A detail route emits its own pair from the counterpart's real Strapi slug
@@ -142,7 +173,7 @@ export function Layout({ children }: { children: ReactNode }) {
const ownsAlternates = useRouteHandleValue(readSelfAlternates) ?? false;
return (
-
+
diff --git a/apps/web/app/styles/README.md b/apps/web/app/styles/README.md
index 026303e..d1bc3bc 100644
--- a/apps/web/app/styles/README.md
+++ b/apps/web/app/styles/README.md
@@ -144,6 +144,18 @@ Because both surface blocks define the *same* token names, a component written
against `--text-primary` is automatically correct on either one - which is why
components must never reach past a role to a primitive.
+**The one global exception, accepted deliberately - the theme toggle.** The
+nav's sun/moon control (Navigation/ThemeToggle.tsx) lets a reader re-root the
+surface the whole document stands on: the root action persists the
+`sharper-theme` cookie, the root loader returns it and `` follows, so a light-theme visit stands on
+`[data-surface="light"]` and every token re-resolves. The mechanism is still
+surfaces-only - the toggle never repaints anything itself, it just chooses
+which pole `:root` behaviour applies to - and the header/footer follow the
+theme unless a route's `handle` forces a surface. `--surface-nav` (the
+translucent bar) must stay defined in BOTH surface blocks, or a light-theme
+nav resolves against the dark block.
+
Utility variants `surface-dark:` and `surface-light:` exist for the rare case a
utility (not a token) has to differ, and `rtl:` / `ltr:` for direction. Prefer
CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `start-`, `end-`) so RTL
diff --git a/apps/web/app/styles/semantic.css b/apps/web/app/styles/semantic.css
index 722642f..05928a2 100644
--- a/apps/web/app/styles/semantic.css
+++ b/apps/web/app/styles/semantic.css
@@ -17,6 +17,13 @@
:root and [data-surface="dark"] share one block; [data-surface="light"]
comes afterwards so it wins on the same element, and proximity makes the
nearest data-surface ancestor win for any subtree.
+
+ THEME TOGGLE (the declared exception to "no global theme"). The nav's
+ theme control flips WHICH surface the DOCUMENT stands on: the root action
+ persists `sharper-theme`, the root loader returns it, and follows - so light mode means the whole site stands on
+ [data-surface="light"] and every token re-resolves, including the nav and
+ footer, which follow the theme unless a route's handle forces a surface.
========================================================================= */
/* ---------------------------------------------------------------------------
@@ -193,6 +200,9 @@
--surface-danger-subtle: var(--color-red-100);
--surface-inverse: var(--color-neutral-900); /* figma background/inverse #0b0b0a */
--surface-footer: var(--color-white); /* figma 140:7812, the light footer */
+ /* The light bar floats the same way the dark one does - the ground colour at
+ 90% with a blur behind it, so content shows through as it scrolls under. */
+ --surface-nav: color-mix(in srgb, var(--color-white) 90%, transparent);
--surface-selection: var(--color-green-200);
/* --- text ------------------------------------------------------------- */