feat: theme toggle

This commit is contained in:
2026-09-23 21:57:24 +02:00
parent 9db3602dea
commit 2663d7eac6
9 changed files with 222 additions and 9 deletions
+6 -1
View File
@@ -53,7 +53,12 @@ unreachable — the site must never 500 because Strapi is down.
The site ships RTL Arabic; physical directions silently break it. 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. 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 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 `<html>`
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`, 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`, `get_variable_defs`. Never call anything that writes (`use_figma`, `generate_figma_design`,
`create_new_file`, `upload_assets`, shaders, plugins, …). Before the first `create_new_file`, `upload_assets`, shaders, plugins, …). Before the first
@@ -10,6 +10,7 @@ import {
import { Brand } from "./Brand"; import { Brand } from "./Brand";
import { DesktopNav } from "./DesktopNav"; import { DesktopNav } from "./DesktopNav";
import { MobileNav } from "./MobileNav"; import { MobileNav } from "./MobileNav";
import { ThemeToggle } from "./ThemeToggle";
import { getDefaultNavItems, getNavigationStrings } from "./nav-items"; import { getDefaultNavItems, getNavigationStrings } from "./nav-items";
import { cx } from "./styles"; import { cx } from "./styles";
import type { NavItem, NavigationStrings, NavSurface } from "./types"; import type { NavItem, NavigationStrings, NavSurface } from "./types";
@@ -53,9 +54,10 @@ export interface NavigationProps {
* and the whole tree moves into a full-screen drawer. * and the whole tree moves into a full-screen drawer.
* *
* EU VISIBILITY: the updated design has no funding badge in the bar - the * 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 * Actions slot holds the language globe and the theme toggle. The mandatory
* European Union" lockup is carried by <Footer />, which renders on every page, * "Funded by the European Union" lockup is carried by <Footer />, which
* and by the mobile drawer; it is not dropped from the site. * renders on every page, and by the mobile drawer; it is not dropped from the
* site.
*/ */
export function Navigation({ export function Navigation({
locale = "en", locale = "en",
@@ -148,6 +150,11 @@ export function Navigation({
</div> </div>
)} )}
{/* 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. */}
<ThemeToggle locale={locale} />
<MobileNav <MobileNav
items={navItems} items={navItems}
pathname={pathname} pathname={pathname}
@@ -0,0 +1,111 @@
import { useMemo } from "react";
import { useFetcher, useRouteLoaderData } from "react-router";
import { createTranslator } from "~/i18n/t";
import type { AppTheme } from "~/lib/theme";
import type { Locale } from "../LanguageSwitcher";
import { cx, FOCUS_RING, HOVER_TRANSITION } from "./styles";
/** The document surface the root loader currently paints on <html>. */
interface RootLoaderData {
theme?: AppTheme;
}
/** A minimal sun, for "switch to light". Decorative - the label calls it. */
function SunIcon() {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
);
}
/** A minimal crescent moon, for "switch to dark". Decorative. */
function MoonIcon() {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
);
}
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 <html>, 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<RootLoaderData>("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 (
<fetcher.Form action="/" method="post">
<input type="hidden" name="theme" value={targetTheme} />
<button
type="submit"
aria-label={label}
title={label}
className={cx(
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center",
"rounded-control text-[var(--text-secondary)]",
"hover:text-[var(--text-primary)]",
HOVER_TRANSITION,
FOCUS_RING,
className,
)}
>
{targetTheme === "light" ? <SunIcon /> : <MoonIcon />}
</button>
</fetcher.Form>
);
}
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.
<ThemeToggle locale={locale} />
------------------------------------------------------------------------ */
+2
View File
@@ -17,6 +17,8 @@
"closeMenu": "إغلاق القائمة", "closeMenu": "إغلاق القائمة",
"homeLinkLabel": "الصفحة الرئيسية لمشروع SHARP-ER", "homeLinkLabel": "الصفحة الرئيسية لمشروع SHARP-ER",
"languageMenu": "اللغة", "languageMenu": "اللغة",
"themeToLight": "التبديل إلى المظهر الفاتح",
"themeToDark": "التبديل إلى المظهر الداكن",
"skipToContent": "تخطَّ إلى المحتوى", "skipToContent": "تخطَّ إلى المحتوى",
"sections": { "sections": {
"home": "الرئيسية", "home": "الرئيسية",
+2
View File
@@ -17,6 +17,8 @@
"closeMenu": "Close menu", "closeMenu": "Close menu",
"homeLinkLabel": "SHARP-ER home", "homeLinkLabel": "SHARP-ER home",
"languageMenu": "Language", "languageMenu": "Language",
"themeToLight": "Switch to light theme",
"themeToDark": "Switch to dark theme",
"skipToContent": "Skip to content", "skipToContent": "Skip to content",
"sections": { "sections": {
"home": "Home", "home": "Home",
+33
View File
@@ -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`;
}
+36 -5
View File
@@ -4,7 +4,9 @@
`Layout` renders the whole document, which is why <html lang> and <html dir> `Layout` renders the whole document, which is why <html lang> and <html dir>
are correct in the FIRST SSR byte: the locale is read from the URL, not from 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 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 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 ErrorBoundary keeps its chrome: a 404 is a normal page of the site, with the
@@ -43,6 +45,7 @@ import {
} from "~/lib/route-handle"; } from "~/lib/route-handle";
import { alternateUrls, buildMeta, canonicalUrl } from "~/lib/seo"; import { alternateUrls, buildMeta, canonicalUrl } from "~/lib/seo";
import { buildFooterLinkGroups, buildNavItems } from "~/lib/site-navigation"; 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 * 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), dir: dirFor(locale),
pathname, pathname,
siteUrl: getSiteUrl(request), 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 <html>.
*/
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 { function pathnameLocale(pathname: string): Locale {
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase(); const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
return first === "ar" ? "ar" : first === "en" ? "en" : DEFAULT_LOCALE; 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 navItems = useMemo(() => buildNavItems(locale), [locale]);
const footerLinkGroups = useMemo(() => buildFooterLinkGroups(locale), [locale]); const footerLinkGroups = useMemo(() => buildFooterLinkGroups(locale), [locale]);
const navSurface = useRouteHandleValue(readNavSurface) ?? "dark"; /* The theme cookie decides which surface the DOCUMENT stands on. The header
const footerSurface = useRouteHandleValue(readFooterSurface) ?? "dark"; 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<typeof loader>("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 /* 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 `meta` replaces its parents' wholesale, so a page that writes its own
would silently drop them. `handle.canonicalPath` is the escape hatch. */ would silently drop them. `handle.canonicalPath` is the escape hatch. */
const rootData = useRouteLoaderData<typeof loader>("root");
const siteUrl = rootData?.siteUrl ?? FALLBACK_SITE_URL; const siteUrl = rootData?.siteUrl ?? FALLBACK_SITE_URL;
const canonicalPath = useRouteHandleValue(readCanonicalPath) ?? pathname; const canonicalPath = useRouteHandleValue(readCanonicalPath) ?? pathname;
/* A detail route emits its own pair from the counterpart's real Strapi slug /* 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; const ownsAlternates = useRouteHandleValue(readSelfAlternates) ?? false;
return ( return (
<html lang={locale} dir={dir} data-surface="dark"> <html lang={locale} dir={dir} data-surface={theme}>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
+12
View File
@@ -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 against `--text-primary` is automatically correct on either one - which is why
components must never reach past a role to a primitive. 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 `<html
data-surface={theme}>` 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 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 utility (not a token) has to differ, and `rtl:` / `ltr:` for direction. Prefer
CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `start-`, `end-`) so RTL CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `start-`, `end-`) so RTL
+10
View File
@@ -17,6 +17,13 @@
:root and [data-surface="dark"] share one block; [data-surface="light"] :root and [data-surface="dark"] share one block; [data-surface="light"]
comes afterwards so it wins on the same element, and proximity makes the comes afterwards so it wins on the same element, and proximity makes the
nearest data-surface ancestor win for any subtree. 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 <html
data-surface> 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-danger-subtle: var(--color-red-100);
--surface-inverse: var(--color-neutral-900); /* figma background/inverse #0b0b0a */ --surface-inverse: var(--color-neutral-900); /* figma background/inverse #0b0b0a */
--surface-footer: var(--color-white); /* figma 140:7812, the light footer */ --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); --surface-selection: var(--color-green-200);
/* --- text ------------------------------------------------------------- */ /* --- text ------------------------------------------------------------- */