Toggle the color scheme from the rail header

Add a moon/sun button beside the rail's Add icon that flips this device
between light and dark, stored under the same theme key the theme picker
and ThemeInitScript read. Following the device stays on Settings > General,
since the toggle is always an explicit choice.
This commit is contained in:
2026-10-22 12:02:04 +01:00
parent 1a6249afd3
commit 379ef35e08
2 changed files with 63 additions and 1 deletions
@@ -3,6 +3,7 @@ import Link from "@moduletrace/twui/layout/Link";
import Wordmark from "@/src/components/general/wordmark"; import Wordmark from "@/src/components/general/wordmark";
import LogoIcon from "@/src/components/general/logo-icon"; import LogoIcon from "@/src/components/general/logo-icon";
import RailAddButton from "./rail-add-button"; import RailAddButton from "./rail-add-button";
import RailThemeToggle from "./rail-theme-toggle";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { useContext } from "react"; import { useContext } from "react";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
@@ -47,7 +48,10 @@ export default function RailHeader({ show_add = true }: Props) {
</> </>
)} )}
</Link> </Link>
<Row className="hidden @rail:flex shrink-0 flex-nowrap items-center gap-1! border-0">
<RailThemeToggle />
{show_add ? <RailAddButton /> : null} {show_add ? <RailAddButton /> : null}
</Row> </Row>
</Row>
); );
} }
@@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Moon, Sun } from "lucide-react";
import useThemePreference from "@/src/hooks/use-theme-preference";
import resolveTheme from "@/src/utils/resolve-theme";
const DarkQuery = "(prefers-color-scheme: dark)";
/**
* The rail header's scheme toggle: one click flips between light and dark
* for this device, stored where `ThemeInitScript` reads it. Following the
* device stays on Settings > General, since a toggle is always an explicit
* choice. It renders after mount only, because the painted scheme is
* decided on the client.
*/
export default function RailThemeToggle() {
const { preference, setPreference } = useThemePreference();
const [mounted, setMounted] = useState(false);
const [system_is_dark, setSystemIsDark] = useState(false);
useEffect(() => {
setMounted(true);
const media = window.matchMedia(DarkQuery);
const onChange = () => setSystemIsDark(media.matches);
onChange();
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, []);
if (!mounted) return null;
const is_dark =
(preference
? resolveTheme({ preference, system_is_dark })
: system_is_dark
? "dark"
: "light") == "dark";
const title = is_dark ? "Switch to the light theme" : "Switch to the dark theme";
const Icon = is_dark ? Moon : Sun;
return (
<Button
title={title}
aria-label={title}
type="button"
variant="ghost"
color="text"
onClick={() => setPreference(is_dark ? "light" : "dark")}
className="hidden @rail:flex shrink-0 p-1.5! rounded-default-sm text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<Icon size={18} aria-hidden />
</Button>
);
}