Add theming infrastructure (task 08)

CSS-variable multi-theme system (default + warm) × {light, dark, system}; per-user theme/theme_mode columns; no-flash pre-paint script; useTheme hook + ThemePicker component on /settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ginnoir
2026-05-06 02:50:05 -05:00
co-authored by Claude Sonnet 4.6
parent 6710c8b231
commit 8cc2ef0732
12 changed files with 540 additions and 3 deletions
+71
View File
@@ -116,6 +116,77 @@
--sidebar-ring: oklch(0.556 0 0);
}
/* Warm theme — light */
[data-theme="warm"] {
--background: oklch(0.98 0.012 60);
--foreground: oklch(0.18 0.03 50);
--card: oklch(0.97 0.014 58);
--card-foreground: oklch(0.18 0.03 50);
--popover: oklch(0.97 0.014 58);
--popover-foreground: oklch(0.18 0.03 50);
--primary: oklch(0.52 0.18 40);
--primary-foreground: oklch(0.98 0.01 60);
--secondary: oklch(0.92 0.02 60);
--secondary-foreground: oklch(0.25 0.04 45);
--muted: oklch(0.93 0.018 58);
--muted-foreground: oklch(0.52 0.04 50);
--accent: oklch(0.90 0.03 55);
--accent-foreground: oklch(0.22 0.04 45);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.88 0.025 60);
--input: oklch(0.88 0.025 60);
--ring: oklch(0.65 0.12 45);
--chart-1: oklch(0.72 0.16 40);
--chart-2: oklch(0.62 0.14 55);
--chart-3: oklch(0.52 0.12 45);
--chart-4: oklch(0.45 0.10 40);
--chart-5: oklch(0.35 0.08 35);
--radius: 0.625rem;
--sidebar: oklch(0.94 0.018 58);
--sidebar-foreground: oklch(0.18 0.03 50);
--sidebar-primary: oklch(0.52 0.18 40);
--sidebar-primary-foreground: oklch(0.98 0.01 60);
--sidebar-accent: oklch(0.90 0.03 55);
--sidebar-accent-foreground: oklch(0.22 0.04 45);
--sidebar-border: oklch(0.88 0.025 60);
--sidebar-ring: oklch(0.65 0.12 45);
}
/* Warm theme — dark */
[data-theme="warm"].dark {
--background: oklch(0.16 0.025 45);
--foreground: oklch(0.95 0.015 60);
--card: oklch(0.22 0.03 48);
--card-foreground: oklch(0.95 0.015 60);
--popover: oklch(0.22 0.03 48);
--popover-foreground: oklch(0.95 0.015 60);
--primary: oklch(0.78 0.14 50);
--primary-foreground: oklch(0.16 0.025 45);
--secondary: oklch(0.28 0.04 50);
--secondary-foreground: oklch(0.95 0.015 60);
--muted: oklch(0.28 0.035 48);
--muted-foreground: oklch(0.68 0.04 55);
--accent: oklch(0.30 0.045 50);
--accent-foreground: oklch(0.95 0.015 60);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.55 0.08 50);
--chart-1: oklch(0.78 0.14 50);
--chart-2: oklch(0.65 0.12 55);
--chart-3: oklch(0.52 0.10 48);
--chart-4: oklch(0.42 0.08 42);
--chart-5: oklch(0.32 0.06 38);
--sidebar: oklch(0.20 0.03 47);
--sidebar-foreground: oklch(0.95 0.015 60);
--sidebar-primary: oklch(0.78 0.14 50);
--sidebar-primary-foreground: oklch(0.16 0.025 45);
--sidebar-accent: oklch(0.30 0.045 50);
--sidebar-accent-foreground: oklch(0.95 0.015 60);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.55 0.08 50);
}
@layer base {
* {
@apply border-border outline-ring/50;
+46 -2
View File
@@ -4,6 +4,10 @@ import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
import { AppNav } from "@/components/app-nav";
import "@/modules"; // registers all module manifests
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { eq } from "drizzle-orm";
import { users } from "@/modules/_core/schema";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
@@ -12,13 +16,53 @@ export const metadata: Metadata = {
description: "Family coordination app",
};
export default function RootLayout({
// Runs before paint — reads localStorage / prefers-color-scheme and applies
// data-theme + dark class to <html> so signed-out pages also get the right theme.
const prePaintScript = `(function(){
try {
var t = localStorage.getItem('theme') || 'default';
var m = localStorage.getItem('themeMode') || 'system';
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', t);
if (dark) document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
} catch(e) {}
})();`;
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
let theme = "default";
let themeMode = "system";
const session = await auth();
if (session?.user?.id) {
const [row] = await db
.select({ theme: users.theme, themeMode: users.themeMode })
.from(users)
.where(eq(users.id, session.user.id))
.limit(1);
if (row) {
theme = row.theme;
themeMode = row.themeMode;
}
}
// For system mode we can't know the preference on the server — the inline
// script will correct it before paint. We optimistically render light here.
const isDark = themeMode === "dark";
return (
<html lang="en" className={cn("font-sans", geist.variable)}>
<html
lang="en"
data-theme={theme}
className={cn("font-sans", geist.variable, isDark ? "dark" : "")}
>
<head>
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
</head>
<body className="min-h-screen">
<AppNav />
<main>{children}</main>
+25
View File
@@ -0,0 +1,25 @@
"use server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema";
import { VALID_THEME_IDS, VALID_THEME_MODES } from "@/modules/_core/themes";
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
import { getCurrentSession } from "@/lib/session";
export async function setUserTheme({
theme,
mode,
}: {
theme: ThemeId;
mode: ThemeMode;
}) {
if (!VALID_THEME_IDS.has(theme)) throw new Error("Invalid theme");
if (!VALID_THEME_MODES.has(mode)) throw new Error("Invalid theme mode");
const { user } = await getCurrentSession();
await db
.update(users)
.set({ theme, themeMode: mode })
.where(eq(users.id, user.id));
}
+34
View File
@@ -0,0 +1,34 @@
import { getCurrentSession } from "@/lib/session";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ThemePicker } from "@/components/theme-picker";
import Link from "next/link";
export default async function SettingsPage() {
const { user } = await getCurrentSession();
return (
<div className="container max-w-2xl py-8 space-y-6">
<h1 className="text-2xl font-semibold">Settings</h1>
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
</CardHeader>
<CardContent>
<ThemePicker
initialTheme={user.theme as "default" | "warm"}
initialMode={user.themeMode as "light" | "dark" | "system"}
signedIn
/>
</CardContent>
</Card>
<Link
href="/settings/household"
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
>
Household Settings
</Link>
</div>
);
}