Full light/dark theme toggle; brand-aware connector lines

- Theme is now class-based (.dark on <html>) with a System/Light/Dark toggle in
  the sidebar, persisted to localStorage and applied pre-paint by an inline
  script (no flash). Replaces the prefers-color-scheme-only behavior, so a phone
  on a light OS theme can still choose dark and vice versa.
- New brand-derived --line token (Ink at 55%): a dark line on the light paper,
  light on dark. The family-chart tree connectors had the library's default
  white stroke and were invisible in light mode — now they use --line, as do
  the pedigree brackets and the fan-chart sectors.
- Light/dark tokens use the exact brand palette (Ink/Muted flip; Bronze/Paper
  constant).

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:48:59 -04:00
parent 548e883d82
commit e8839b15a0
6 changed files with 91 additions and 15 deletions
+6 -1
View File
@@ -17,6 +17,7 @@ import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { cn } from "@/lib/utils";
import { ThemeToggle } from "@/components/theme-toggle";
export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
@@ -136,7 +137,11 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
</div>
)}
<div ref={menuRef} className="relative mt-auto">
<div className="mt-auto">
<ThemeToggle />
</div>
<div ref={menuRef} className="relative">
{menuOpen && (
<div className="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
<Link
+1 -1
View File
@@ -79,7 +79,7 @@ export function FanChart({
<path
d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)}
fill={id ? "var(--surface)" : "transparent"}
stroke="var(--border)"
stroke="var(--line)"
/>
{id && (
<text
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { Monitor, Moon, Sun } from "lucide-react";
import { useEffect, useState } from "react";
type Theme = "light" | "dark" | "system";
const ORDER: Theme[] = ["system", "light", "dark"];
const ICON = { system: Monitor, light: Sun, dark: Moon };
const LABEL = { system: "System", light: "Light", dark: "Dark" };
function apply(theme: Theme) {
const dark =
theme === "dark" ||
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
}
/** Cycles theme System → Light → Dark, persisting the choice in localStorage. */
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
const saved = (localStorage.getItem("theme") as Theme) || "system";
setTheme(saved);
}, []);
// Keep "system" in sync if the OS preference changes while selected.
useEffect(() => {
if (theme !== "system") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => apply("system");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [theme]);
function cycle() {
const next = ORDER[(ORDER.indexOf(theme) + 1) % ORDER.length];
localStorage.setItem("theme", next);
setTheme(next);
apply(next);
}
const Icon = ICON[theme];
return (
<button
onClick={cycle}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-colors hover:bg-bronze/[0.07] hover:text-[var(--foreground)]"
title={`Theme: ${LABEL[theme]} (click to change)`}
>
<Icon className="h-4 w-4 shrink-0" />
Theme: {LABEL[theme]}
</button>
);
}