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
+36 -5
View File
@@ -4,7 +4,9 @@
`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
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 <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 {
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<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
`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<typeof loader>("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 (
<html lang={locale} dir={dir} data-surface="dark">
<html lang={locale} dir={dir} data-surface={theme}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />