More-Theme-Stuff #5

Merged
brianfertig merged 3 commits from More-Theme-Stuff into main 2026-08-30 04:08:19 +00:00
15 changed files with 512 additions and 118 deletions
Showing only changes of commit f66c7731d1 - Show all commits

View File

@ -2,6 +2,8 @@ import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import type { ThemeName } from "@/lib/themes";
import type { ProjectDTO } from "@/types/project";
import { SideNavProvider } from "@/components/nav/side-nav-provider"; import { SideNavProvider } from "@/components/nav/side-nav-provider";
import { SideNav } from "@/components/nav/side-nav"; import { SideNav } from "@/components/nav/side-nav";
import { ProjectsProvider } from "@/components/projects/projects-context"; import { ProjectsProvider } from "@/components/projects/projects-context";
@ -18,11 +20,19 @@ export default async function AppLayout({ children }: { children: React.ReactNod
// Just the list for the sidebar/`/projects` page -- each project's own // Just the list for the sidebar/`/projects` page -- each project's own
// board (categories/groups/todos) is fetched separately by its own page. // board (categories/groups/todos) is fetched separately by its own page.
const projects = await prisma.project.findMany({ // `theme` is stored as free text; the set of valid names is enforced
where: { ownerId: session.user.id }, // client-side (ProjectThemeSchema) so a plain assertion is safe here.
orderBy: { createdAt: "asc" }, const projects: ProjectDTO[] = (
select: { id: true, title: true }, await prisma.project.findMany({
}); where: { ownerId: session.user.id },
orderBy: { createdAt: "asc" },
select: { id: true, title: true, theme: true },
})
).map((p) => ({
id: p.id,
title: p.title,
theme: p.theme as ThemeName | null,
}));
return ( return (
<SideNavProvider> <SideNavProvider>

View File

@ -5,7 +5,9 @@ import { prisma } from "@/lib/db";
import { projectAccessFilter } from "@/lib/access"; import { projectAccessFilter } from "@/lib/access";
import { getBoard } from "@/lib/board"; import { getBoard } from "@/lib/board";
import { getAiSettingsView } from "@/lib/ai-settings"; import { getAiSettingsView } from "@/lib/ai-settings";
import { themeSwitchScript, type ThemeName } from "@/lib/themes";
import { KanbanBoard } from "@/components/board/kanban-board"; import { KanbanBoard } from "@/components/board/kanban-board";
import { ProjectThemeScope } from "@/components/theme/project-theme-scope";
export default async function ProjectPage({ export default async function ProjectPage({
params, params,
@ -18,7 +20,7 @@ export default async function ProjectPage({
const project = await prisma.project.findFirst({ const project = await prisma.project.findFirst({
where: { id: projectId, ...projectAccessFilter(session.user.id) }, where: { id: projectId, ...projectAccessFilter(session.user.id) },
select: { id: true, title: true }, select: { id: true, title: true, theme: true },
}); });
// Same response whether the project doesn't exist or just isn't this // Same response whether the project doesn't exist or just isn't this
// user's -- no need to distinguish "not found" from "not yours". // user's -- no need to distinguish "not found" from "not yours".
@ -30,12 +32,28 @@ export default async function ProjectPage({
]); ]);
const aiConfigured = !!(aiSettings.apiUrl && aiSettings.model); const aiConfigured = !!(aiSettings.apiUrl && aiSettings.model);
// A project's assigned theme (null = "Default Theme", i.e. the user's
// global theme-menu choice) applies while this page is open and lifts on
// navigation -- ProjectThemeScope owns the client side. The inline script
// mirrors it on <html> before first paint on a hard load, so the first
// frame is already themed (and leaves the data-project-theme marker the
// ThemeProvider reads at hydration). `theme` is free text in the DB; the
// set of valid names is enforced by ProjectThemeSchema on write, so the
// assertion is safe.
const scopedTheme: ThemeName | null = project.theme as ThemeName | null;
return ( return (
<KanbanBoard <>
initialCategories={board} {scopedTheme && (
projectId={project.id} <script dangerouslySetInnerHTML={{ __html: themeSwitchScript(scopedTheme) }} />
title={project.title} )}
aiConfigured={aiConfigured} <ProjectThemeScope projectId={project.id} />
/> <KanbanBoard
initialCategories={board}
projectId={project.id}
title={project.title}
aiConfigured={aiConfigured}
/>
</>
); );
} }

View File

@ -23,6 +23,7 @@ import { EmptyState } from "@/components/board/empty-state";
import { ViewSwitcher } from "@/components/board/view-switcher"; import { ViewSwitcher } from "@/components/board/view-switcher";
import { SummaryButton } from "@/components/board/summary-button"; import { SummaryButton } from "@/components/board/summary-button";
import { TodoCreateDialog } from "@/components/board/todo-create-dialog"; import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
import { ProjectThemePicker } from "@/components/projects/project-theme-picker";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { CategoryDTO, GroupDTO } from "@/types/board"; import type { CategoryDTO, GroupDTO } from "@/types/board";
@ -146,7 +147,12 @@ function Board({
<LayoutDashboard className="size-5" /> <LayoutDashboard className="size-5" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<h1 className="truncate font-heading text-xl font-bold leading-tight">{title}</h1> <div className="flex min-w-0 items-center gap-2">
<h1 className="truncate font-heading text-xl font-bold leading-tight">{title}</h1>
{/* Per-project theme picker, to the right of the project's
name -- only projects have their own theme assignment. */}
{projectId && <ProjectThemePicker projectId={projectId} />}
</div>
<p className="truncate text-[13px] text-muted-foreground"> <p className="truncate text-[13px] text-muted-foreground">
{categories.length === 0 {categories.length === 0
? "Add a lane to get started" ? "Add a lane to get started"

View File

@ -0,0 +1,145 @@
"use client";
import { Check, ChevronDown, Monitor, Palette } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useProjects } from "@/components/projects/projects-context";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
LIGHT_OPTIONS,
DARK_OPTIONS,
type ThemeOption,
} from "@/components/theme/theme-toggle";
import { useTheme, type ThemeName } from "@/components/theme/theme-provider";
const DEFAULT_LABEL = "Default theme";
/** One named-theme row. Uses DropdownMenuItem (a real menu item) so the
* menu closes on selection, matching the app's other menus. */
function ThemeOptionRow({
option,
active,
onSelect,
}: {
option: ThemeOption;
active: boolean;
onSelect: () => void;
}) {
const Icon: LucideIcon = option.icon;
return (
<DropdownMenuItem
onClick={onSelect}
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
>
<Icon className="size-4 text-muted-foreground" />
<span>{option.label}</span>
<span
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
style={{ backgroundColor: option.swatch }}
aria-hidden
/>
{active && <Check className="size-4 shrink-0" />}
</DropdownMenuItem>
);
}
/**
* The per-project theme picker, rendered to the right of a project's name
* (see components/board/kanban-board.tsx).
*
* "Default theme" (project.theme = null) means "whatever the user assigned
* in the global theme menu" -- the same value this picker's checkmark and
* label track, live. Picking a named theme stores it on the project; the
* project page then scopes the app to that theme while it's open
* (components/theme/project-theme-scope.tsx).
*/
export function ProjectThemePicker({ projectId }: { projectId: string }) {
const { projects, setProjectTheme } = useProjects();
const { resolvedTheme } = useTheme();
const project = projects.find((p) => p.id === projectId);
if (!project) return null;
const theme: ThemeName | null = project.theme;
const activeOption = theme
? [...LIGHT_OPTIONS, ...DARK_OPTIONS].find((o) => o.value === theme)
: undefined;
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 rounded-lg px-2 text-[13px] text-muted-foreground hover:text-foreground"
aria-label="Choose this project's theme"
>
<Palette className="size-3.5" />
<span className="max-w-36 truncate">
{theme ? activeOption?.label ?? DEFAULT_LABEL : DEFAULT_LABEL}
</span>
<ChevronDown className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="start" className="w-60">
<DropdownMenuItem
onClick={() => setProjectTheme(projectId, null)}
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
>
<Monitor className="size-4 text-muted-foreground" />
<span className="min-w-0">
<span className="block truncate">Default theme</span>
<span className="block truncate text-xs text-muted-foreground">
Follows the global theme menu
</span>
</span>
<span
className="ml-auto size-3 shrink-0 rounded-full ring-1 ring-foreground/15"
style={{
background: "linear-gradient(90deg, #4f56e0 50%, #7c83f2 50%)",
}}
aria-hidden
/>
{theme === null && <Check className="size-4 shrink-0" />}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Light</DropdownMenuLabel>
{LIGHT_OPTIONS.map((option) => (
<ThemeOptionRow
key={option.value}
option={option}
active={theme === option.value}
onSelect={() => setProjectTheme(projectId, option.value)}
/>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>Dark</DropdownMenuLabel>
{DARK_OPTIONS.map((option) => (
<ThemeOptionRow
key={option.value}
option={option}
active={theme === option.value}
onSelect={() => setProjectTheme(projectId, option.value)}
/>
))}
<DropdownMenuSeparator />
<p className="px-2 py-1.5 text-xs leading-snug text-muted-foreground">
{theme === null
? `Currently following your global theme (now: ${
resolvedTheme.charAt(0).toUpperCase() + resolvedTheme.slice(1)
}).`
: `This theme applies only while "${project.title}" is open.`}
</p>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -3,11 +3,13 @@
import { createContext, useContext, useState, useCallback } from "react"; import { createContext, useContext, useState, useCallback } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ThemeName } from "@/lib/themes";
import type { ProjectDTO } from "@/types/project"; import type { ProjectDTO } from "@/types/project";
import { import {
createProject, createProject,
renameProject as renameProjectAction, renameProject as renameProjectAction,
deleteProject as deleteProjectAction, deleteProject as deleteProjectAction,
setProjectTheme as setProjectThemeAction,
} from "@/lib/actions/projects"; } from "@/lib/actions/projects";
interface ProjectsContextValue { interface ProjectsContextValue {
@ -15,6 +17,10 @@ interface ProjectsContextValue {
addProject: (title: string) => Promise<ProjectDTO | undefined>; addProject: (title: string) => Promise<ProjectDTO | undefined>;
renameProject: (projectId: string, title: string) => Promise<void>; renameProject: (projectId: string, title: string) => Promise<void>;
removeProject: (projectId: string) => Promise<boolean>; removeProject: (projectId: string) => Promise<boolean>;
/** Assign a project theme, or pass null for "Default Theme" (the user's
* global preference). Optimistic: the new value lands immediately and
* rolls back on failure. */
setProjectTheme: (projectId: string, theme: ThemeName | null) => Promise<void>;
} }
const ProjectsContext = createContext<ProjectsContextValue | null>(null); const ProjectsContext = createContext<ProjectsContextValue | null>(null);
@ -75,8 +81,27 @@ export function ProjectsProvider({
return true; return true;
}, []); }, []);
const setProjectTheme = useCallback(
async (projectId: string, theme: ThemeName | null) => {
let prevState: ProjectDTO[] = [];
setProjects((prev) => {
prevState = prev;
return prev.map((p) => (p.id === projectId ? { ...p, theme } : p));
});
try {
await setProjectThemeAction(projectId, theme);
} catch {
setProjects(prevState);
toast.error("Couldn't save the project theme. Try again.");
}
},
[]
);
return ( return (
<ProjectsContext.Provider value={{ projects, addProject, renameProject, removeProject }}> <ProjectsContext.Provider
value={{ projects, addProject, renameProject, removeProject, setProjectTheme }}
>
{children} {children}
</ProjectsContext.Provider> </ProjectsContext.Provider>
); );

View File

@ -0,0 +1,37 @@
"use client";
import { useEffect } from "react";
import { useProjects } from "@/components/projects/projects-context";
import { useThemeScope } from "./theme-provider";
/**
* Temporarily scopes the app's theme (see ThemeProvider in
* theme-provider.tsx) to the project's assigned theme while that project's
* page is on screen. The theme lifts the moment you navigate away, so the
* rest of the app (Home, other projects) keeps the user's global
* preference.
*
* The theme is read from the live ProjectsContext -- not a page prop -- so
* picking a new theme in the picker transforms the page *immediately*,
* without waiting for the server re-render after the action settles.
* `null` (the "Default theme" state) is a no-op: the page follows the
* global theme, and changing the global theme from the theme menu while on
* the page takes effect here too.
*
* The provider never learns the preference from this component (the global
* `theme` state and localStorage are untouched).
*/
export function ProjectThemeScope({ projectId }: { projectId: string }) {
const { projects } = useProjects();
const { setScope } = useThemeScope();
const theme = projects.find((p) => p.id === projectId)?.theme ?? null;
useEffect(() => {
setScope(theme);
return () => setScope(null);
}, [theme, setScope]);
return null;
}

View File

@ -11,6 +11,19 @@ import {
} from "react"; } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import {
THEMES,
THEME_CLASSES,
DARK_SURFACES,
themeColorScheme,
type ThemeName,
type RawTheme,
} from "@/lib/themes";
// Re-exported so existing imports keep working.
export { THEMES, DARK_SURFACES };
export type { ThemeName, RawTheme };
/** /**
* App theme provider (replaces next-themes). * App theme provider (replaces next-themes).
* *
@ -23,73 +36,25 @@ import type { ReactNode } from "react";
* *
* Semantics (kept compatible for all existing consumers): * Semantics (kept compatible for all existing consumers):
* - `theme` the stored preference: one of THEMES, or "system" * - `theme` the stored preference: one of THEMES, or "system"
* - `resolvedTheme` the theme actually applied (system resolved via * - `resolvedTheme` the theme actually applied, always one of THEMES
* prefers-color-scheme), always one of THEMES
* - html classes .theme-default | .theme-sunset | ... (see globals.css) * - html classes .theme-default | .theme-sunset | ... (see globals.css)
*
* Theme scoping (per-project themes):
* A subtree can temporarily override the global preference with
* `useThemeScope().setScope(name)` -- the project pages use this so a
* project's assigned theme applies while its page is open and lifts on
* navigation (see components/theme/project-theme-scope.tsx). While a scope
* is active it wins over `theme` for `resolvedTheme` and the DOM; the
* stored global preference is never touched, so changing the global theme
* from the theme menu while inside a scoped project is fine (it applies
* everywhere *except* the scoped page, which keeps its own theme).
* The provider also picks up a `data-project-theme` marker on <html> at
* mount -- set by the project page's no-FOUC inline script -- so a scoped
* theme is in effect from the very first frame of a hard page load.
*/ */
/* Order is the picker order: all the light looks, then the dark looks. */
export const THEMES = [
"default",
"sunset",
"meadow",
"honey",
"rose",
"lavender",
"slate",
"blueprint",
"vaporwave",
"notebook",
"dark",
"ocean",
"pine",
"plum",
"midnight",
"ember",
"rosewood",
"cyberpunk",
"starfield",
] as const;
export type ThemeName = (typeof THEMES)[number];
export type RawTheme = ThemeName | "system";
const STORAGE_KEY = "theme"; const STORAGE_KEY = "theme";
const THEME_CLASSES: Record<ThemeName, string> = {
default: "theme-default",
sunset: "theme-sunset",
meadow: "theme-meadow",
honey: "theme-honey",
rose: "theme-rose",
lavender: "theme-lavender",
slate: "theme-slate",
blueprint: "theme-blueprint",
vaporwave: "theme-vaporwave",
notebook: "theme-notebook",
dark: "dark",
ocean: "ocean",
pine: "theme-pine",
plum: "theme-plum",
midnight: "theme-midnight",
ember: "theme-ember",
rosewood: "theme-rosewood",
cyberpunk: "theme-cyberpunk",
starfield: "theme-starfield",
};
const ALL_CLASSES = Object.values(THEME_CLASSES); const ALL_CLASSES = Object.values(THEME_CLASSES);
/* Themes whose *surfaces* are dark (see app/globals.css). The single source
* of truth for every dark-surface check: isDarkTheme, the sonner toaster,
* color-scheme, and the `dark:` custom-variant in globals.css. */
export const DARK_SURFACES: ThemeName[] = [
"dark",
"ocean",
"pine",
"plum",
"midnight",
"ember",
"rosewood",
"cyberpunk",
"starfield",
];
interface ThemeContextValue { interface ThemeContextValue {
theme: RawTheme; theme: RawTheme;
@ -98,12 +63,21 @@ interface ThemeContextValue {
themes: readonly ThemeName[]; themes: readonly ThemeName[];
} }
interface ThemeScopeValue {
scope: ThemeName | null;
setScope: (scope: ThemeName | null) => void;
}
const ThemeContext = createContext<ThemeContextValue>({ const ThemeContext = createContext<ThemeContextValue>({
theme: "system", theme: "system",
resolvedTheme: "default", resolvedTheme: "default",
setTheme: () => {}, setTheme: () => {},
themes: THEMES, themes: THEMES,
}); });
const ThemeScopeContext = createContext<ThemeScopeValue>({
scope: null,
setScope: () => {},
});
function systemPrefersDark(): boolean { function systemPrefersDark(): boolean {
return ( return (
@ -123,15 +97,25 @@ export function resolveTheme(theme: RawTheme): ThemeName {
return resolve(theme); return resolve(theme);
} }
function applyToDom(theme: RawTheme) { function applyToDom(name: ThemeName) {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
const name = resolve(theme);
const root = document.documentElement; const root = document.documentElement;
// Also drop "light": a class next-themes applied to <html> in earlier // Also drop "light": a class next-themes applied to <html> in earlier
// versions, harmless but stale. // versions, harmless but stale.
root.classList.remove(...ALL_CLASSES, "light"); root.classList.remove(...ALL_CLASSES, "light");
root.classList.add(THEME_CLASSES[name]); root.classList.add(THEME_CLASSES[name]);
root.style.colorScheme = DARK_SURFACES.includes(name) ? "dark" : "light"; root.style.colorScheme = themeColorScheme(name);
}
/** The project page's no-FOUC script leaves this marker behind (see
* themeSwitchScript in lib/themes.ts); read it once at mount so the first
* frame of a hard load is already scoped. */
function readInitialScope(): ThemeName | null {
if (typeof document === "undefined") return null;
const marker = document.documentElement.dataset.projectTheme;
return marker && (THEMES as readonly string[]).includes(marker)
? (marker as ThemeName)
: null;
} }
export function ThemeProvider({ export function ThemeProvider({
@ -146,6 +130,11 @@ export function ThemeProvider({
// The inline script in app/layout.tsx already applied the right classes // The inline script in app/layout.tsx already applied the right classes
// before first paint, so there is no visible jump either way. // before first paint, so there is no visible jump either way.
const [theme, setThemeState] = useState<RawTheme>(defaultTheme); const [theme, setThemeState] = useState<RawTheme>(defaultTheme);
// Starts null (matching the server render); the project-page marker is
// applied in a layout effect below, before paint.
const [scope, setScope] = useState<ThemeName | null>(null);
const resolvedTheme: ThemeName = scope ?? resolve(theme);
useEffect(() => { useEffect(() => {
try { try {
@ -156,21 +145,33 @@ export function ThemeProvider({
} }
}, []); }, []);
// Keep the DOM in sync whenever the preference changes -- synchronously // Pick up a project's no-FOUC marker (hard page load). Runs in the same
// pre-paint window as the apply effect below, so the global-theme flash
// it may cause is never visible. (Mount-time sync from an external
// source, same pattern as the localStorage read above.)
useLayoutEffect(() => {
const initialScope = readInitialScope();
if (initialScope) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- mount-time sync from the <html> marker, see above
setScope(initialScope);
}
}, []);
// Keep the DOM in sync whenever the resolved theme changes -- synchronously
// (layout effect) so the CSS classes land in the same frame as the // (layout effect) so the CSS classes land in the same frame as the
// JS-driven colors that derive from this state: no flash of stale colors. // JS-driven colors that derive from this state: no flash of stale colors.
useLayoutEffect(() => { useLayoutEffect(() => {
applyToDom(theme); applyToDom(resolvedTheme);
}, [theme]); }, [resolvedTheme]);
// Follow the OS while in "system" mode. // Follow the OS while in "system" mode (and nothing is scoped).
useEffect(() => { useEffect(() => {
if (theme !== "system" || !window.matchMedia) return; if (scope || theme !== "system" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)"); const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyToDom("system"); const onChange = () => applyToDom(resolve("system"));
mq.addEventListener("change", onChange); mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange); return () => mq.removeEventListener("change", onChange);
}, [theme]); }, [scope, theme]);
// Keep multiple tabs in sync. // Keep multiple tabs in sync.
useEffect(() => { useEffect(() => {
@ -191,13 +192,25 @@ export function ThemeProvider({
}, []); }, []);
const value = useMemo<ThemeContextValue>( const value = useMemo<ThemeContextValue>(
() => ({ theme, resolvedTheme: resolve(theme), setTheme, themes: THEMES }), () => ({ theme, resolvedTheme, setTheme, themes: THEMES }),
[theme, setTheme], [theme, resolvedTheme, setTheme],
);
const scopeValue = useMemo<ThemeScopeValue>(
() => ({ scope, setScope }),
[scope],
); );
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; return (
<ThemeScopeContext.Provider value={scopeValue}>
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
</ThemeScopeContext.Provider>
);
} }
export function useTheme(): ThemeContextValue { export function useTheme(): ThemeContextValue {
return useContext(ThemeContext); return useContext(ThemeContext);
} }
export function useThemeScope(): ThemeScopeValue {
return useContext(ThemeScopeContext);
}

View File

@ -31,12 +31,13 @@ import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
type ThemeOption = { export type ThemeOption = {
value: ThemeName; value: ThemeName;
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
@ -45,8 +46,9 @@ type ThemeOption = {
// The looks, grouped by surface. Each value is one next-themes value (see // The looks, grouped by surface. Each value is one next-themes value (see
// app/layout.tsx for the class mapping); the little dot is the theme's own // app/layout.tsx for the class mapping); the little dot is the theme's own
// accent so the picker previews the vibe. // accent so the picker previews the vibe. Exported for reuse by other
const LIGHT_OPTIONS: ThemeOption[] = [ // theme pickers (e.g. components/projects/project-theme-picker.tsx).
export const LIGHT_OPTIONS: ThemeOption[] = [
{ value: "default", label: "Default", icon: Sun, swatch: "#4f56e0" }, { value: "default", label: "Default", icon: Sun, swatch: "#4f56e0" },
{ value: "sunset", label: "Sunset", icon: Sunset, swatch: "#c2410c" }, { value: "sunset", label: "Sunset", icon: Sunset, swatch: "#c2410c" },
{ value: "meadow", label: "Meadow", icon: Leaf, swatch: "#059669" }, { value: "meadow", label: "Meadow", icon: Leaf, swatch: "#059669" },
@ -58,7 +60,7 @@ const LIGHT_OPTIONS: ThemeOption[] = [
{ value: "vaporwave", label: "Vaporwave", icon: Disc3, swatch: "#e879f9" }, { value: "vaporwave", label: "Vaporwave", icon: Disc3, swatch: "#e879f9" },
{ value: "notebook", label: "Notebook", icon: NotebookText, swatch: "#3b5fc4" }, { value: "notebook", label: "Notebook", icon: NotebookText, swatch: "#3b5fc4" },
]; ];
const DARK_OPTIONS: ThemeOption[] = [ export const DARK_OPTIONS: ThemeOption[] = [
{ value: "dark", label: "Dark", icon: Moon, swatch: "#7c83f2" }, { value: "dark", label: "Dark", icon: Moon, swatch: "#7c83f2" },
{ value: "ocean", label: "Ocean", icon: Waves, swatch: "#5eead4" }, { value: "ocean", label: "Ocean", icon: Waves, swatch: "#5eead4" },
{ value: "pine", label: "Pine", icon: TreePine, swatch: "#34d399" }, { value: "pine", label: "Pine", icon: TreePine, swatch: "#34d399" },
@ -69,9 +71,9 @@ const DARK_OPTIONS: ThemeOption[] = [
{ value: "cyberpunk", label: "Cyberpunk", icon: CircuitBoard, swatch: "#ff2bd6" }, { value: "cyberpunk", label: "Cyberpunk", icon: CircuitBoard, swatch: "#ff2bd6" },
{ value: "starfield", label: "Starfield", icon: Orbit, swatch: "#38bdf8" }, { value: "starfield", label: "Starfield", icon: Orbit, swatch: "#38bdf8" },
]; ];
const ALL_OPTIONS = [...LIGHT_OPTIONS, ...DARK_OPTIONS]; export const ALL_OPTIONS = [...LIGHT_OPTIONS, ...DARK_OPTIONS];
function OptionRow({ export function OptionRow({
option, option,
active, active,
onSelect, onSelect,
@ -82,20 +84,19 @@ function OptionRow({
}) { }) {
const Icon = option.icon; const Icon = option.icon;
return ( return (
<button <DropdownMenuItem
type="button"
onClick={onSelect} onClick={onSelect}
className="group relative flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50" className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
> >
<Icon className="size-4 text-muted-foreground group-focus:text-current" /> <Icon className="size-4 text-muted-foreground" />
<span>{option.label}</span> <span>{option.label}</span>
<span <span
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15" className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
style={{ backgroundColor: option.swatch }} style={{ backgroundColor: option.swatch }}
aria-hidden aria-hidden
/> />
{active && <Check className="absolute right-2 -mr-4 size-4" />} {active && <Check className="size-4 shrink-0" />}
</button> </DropdownMenuItem>
); );
} }
@ -144,10 +145,9 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
/> />
))} ))}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<button <DropdownMenuItem
type="button"
onClick={() => setTheme("system")} onClick={() => setTheme("system")}
className="relative flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50" className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
> >
<Monitor className="size-4 text-muted-foreground" /> <Monitor className="size-4 text-muted-foreground" />
<span>Match system</span> <span>Match system</span>
@ -158,8 +158,8 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
}} }}
aria-hidden aria-hidden
/> />
{isSystem && <Check className="absolute right-2 -mr-4 size-4" />} {isSystem && <Check className="size-4 shrink-0" />}
</button> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );

View File

@ -2,7 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTheme, resolveTheme, DARK_SURFACES } from "./theme-provider"; import { useTheme, DARK_SURFACES } from "./theme-provider";
import { getGroupColor, getThemedGroupColor, type GroupColor } from "@/lib/colors"; import { getGroupColor, getThemedGroupColor, type GroupColor } from "@/lib/colors";
// The themes whose *surfaces* are dark (see app/globals.css) come from the // The themes whose *surfaces* are dark (see app/globals.css) come from the
@ -24,9 +24,11 @@ function useMounted(): boolean {
* Midnight, Ember, Rosewood, Cyberpunk). * Midnight, Ember, Rosewood, Cyberpunk).
* *
* Reactive to UI theme switches: the value is derived from the provider's * Reactive to UI theme switches: the value is derived from the provider's
* theme state (the single source of truth), so the moment `setTheme` fires, * `resolvedTheme` -- the single source of truth for what's actually
* every consumer re-renders with the new variant in the same commit -- no * applied, including a project's scoped theme (see theme-provider) -- so
* stale card colors, no manual refresh needed. * the moment `setTheme` fires (or a scope lifts), every consumer
* re-renders with the new variant in the same commit: no stale card
* colors, no manual refresh needed.
* *
* Hydration-safe: until mounted we always report "light", matching the * Hydration-safe: until mounted we always report "light", matching the
* server render (the no-FOUC script in app/layout.tsx covers the CSS side * server render (the no-FOUC script in app/layout.tsx covers the CSS side
@ -34,9 +36,9 @@ function useMounted(): boolean {
*/ */
export function isDarkTheme(): boolean { export function isDarkTheme(): boolean {
const mounted = useMounted(); const mounted = useMounted();
const { theme } = useTheme(); const { resolvedTheme } = useTheme();
if (!mounted) return false; if (!mounted) return false;
return DARK_SURFACES.includes(resolveTheme(theme)); return DARK_SURFACES.includes(resolvedTheme);
} }
/** True when the "cyberpunk" theme is active. Used to swap the neon /** True when the "cyberpunk" theme is active. Used to swap the neon
@ -44,9 +46,9 @@ export function isDarkTheme(): boolean {
*/ */
export function isCyberpunkTheme(): boolean { export function isCyberpunkTheme(): boolean {
const mounted = useMounted(); const mounted = useMounted();
const { theme } = useTheme(); const { resolvedTheme } = useTheme();
if (!mounted) return false; if (!mounted) return false;
return resolveTheme(theme) === "cyberpunk"; return resolvedTheme === "cyberpunk";
} }
/** /**
@ -60,9 +62,9 @@ export function isCyberpunkTheme(): boolean {
*/ */
export function useGroupColor(key: string): GroupColor { export function useGroupColor(key: string): GroupColor {
const mounted = useMounted(); const mounted = useMounted();
const { theme } = useTheme(); const { resolvedTheme } = useTheme();
if (!mounted) return getGroupColor(key); if (!mounted) return getGroupColor(key);
return getThemedGroupColor(resolveTheme(theme), key); return getThemedGroupColor(resolvedTheme, key);
} }
/** /**

View File

@ -5,7 +5,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUserId } from "@/lib/auth-helpers"; import { requireUserId } from "@/lib/auth-helpers";
import { projectAccessFilter } from "@/lib/access"; import { projectAccessFilter } from "@/lib/access";
import { ProjectTitleSchema } from "@/lib/validation/project"; import { ProjectTitleSchema, ProjectThemeSchema } from "@/lib/validation/project";
import type { ProjectDTO } from "@/types/project"; import type { ProjectDTO } from "@/types/project";
export async function createProject(title: string): Promise<ProjectDTO> { export async function createProject(title: string): Promise<ProjectDTO> {
@ -17,7 +17,29 @@ export async function createProject(title: string): Promise<ProjectDTO> {
}); });
revalidatePath("/projects"); revalidatePath("/projects");
return { id: project.id, title: project.title }; return { id: project.id, title: project.title, theme: null };
}
/**
* Assigns a theme to a project (or clears it back to "Default Theme" with
* null, which follows the user's global theme preference). Only the
* project's own page is themed -- see components/theme/project-theme-scope.tsx.
*/
export async function setProjectTheme(
projectId: string,
theme: string | null
): Promise<void> {
const userId = await requireUserId();
const parsed = ProjectThemeSchema.parse(theme);
const { count } = await prisma.project.updateMany({
where: { id: projectId, ...projectAccessFilter(userId) },
data: { theme: parsed },
});
if (count === 0) throw new Error("Project not found");
revalidatePath("/projects");
revalidatePath(`/projects/${projectId}`);
} }
export async function renameProject(projectId: string, title: string): Promise<void> { export async function renameProject(projectId: string, title: string): Promise<void> {

96
lib/themes.ts Normal file
View File

@ -0,0 +1,96 @@
/**
* Isomorphic theme metadata.
*
* Deliberately framework-free: it is imported from server code (project
* theme validation in lib/actions/projects.ts, the no-FOUC script rendered
* by the project page) *and* client code (theme provider, pickers), so it
* must stay free of React/DOM imports. What *applies* themes to the DOM
* lives in components/theme/theme-provider.tsx; the display options
* (labels, icons, swatches) live in components/theme/theme-toggle.tsx.
*/
/* Order is the picker order: all the light looks, then the dark looks. */
export const THEMES = [
"default",
"sunset",
"meadow",
"honey",
"rose",
"lavender",
"slate",
"blueprint",
"vaporwave",
"notebook",
"dark",
"ocean",
"pine",
"plum",
"midnight",
"ember",
"rosewood",
"cyberpunk",
"starfield",
] as const;
export type ThemeName = (typeof THEMES)[number];
export type RawTheme = ThemeName | "system";
/** Theme name -> class applied to <html> (see app/globals.css). */
export const THEME_CLASSES: Record<ThemeName, string> = {
default: "theme-default",
sunset: "theme-sunset",
meadow: "theme-meadow",
honey: "theme-honey",
rose: "theme-rose",
lavender: "theme-lavender",
slate: "theme-slate",
blueprint: "theme-blueprint",
vaporwave: "theme-vaporwave",
notebook: "theme-notebook",
dark: "dark",
ocean: "ocean",
pine: "theme-pine",
plum: "theme-plum",
midnight: "theme-midnight",
ember: "theme-ember",
rosewood: "theme-rosewood",
cyberpunk: "theme-cyberpunk",
starfield: "theme-starfield",
};
/** Themes whose *surfaces* are dark (see app/globals.css). The single source
* of truth for every dark-surface check: isDarkTheme, the sonner toaster,
* color-scheme, and the `dark:` custom-variant in globals.css. */
export const DARK_SURFACES: ThemeName[] = [
"dark",
"ocean",
"pine",
"plum",
"midnight",
"ember",
"rosewood",
"cyberpunk",
"starfield",
];
export function themeColorScheme(name: ThemeName): "light" | "dark" {
return DARK_SURFACES.includes(name) ? "dark" : "light";
}
/**
* Inline-script source that switches <html> over to `theme` immediately
* (before first paint), the same way the global no-FOUC script in
* app/layout.tsx works, and leaves a `data-project-theme` marker behind so
* the ThemeProvider can pick the theme up as its initial scope at
* hydration. Render it from a server component with
* `dangerouslySetInnerHTML`.
*/
export function themeSwitchScript(theme: ThemeName): string {
const classes = JSON.stringify([
...Object.values(THEME_CLASSES),
"light",
]);
return `try{var r=document.documentElement;var c=${classes};for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}r.classList.add(${JSON.stringify(
THEME_CLASSES[theme]
)});r.style.colorScheme=${JSON.stringify(
themeColorScheme(theme)
)};r.dataset.projectTheme=${JSON.stringify(theme)};}catch(e){}`;
}

View File

@ -1,3 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { THEMES } from "@/lib/themes";
export const ProjectTitleSchema = z.string().trim().min(1, "Title is required").max(60); export const ProjectTitleSchema = z.string().trim().min(1, "Title is required").max(60);
/** A project's assigned theme: one of the known themes, or null for
* "Default Theme" (follow the user's global theme preference). */
export const ProjectThemeSchema = z.union([z.enum(THEMES), z.null()]);
export type ProjectTheme = z.infer<typeof ProjectThemeSchema>;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "theme" TEXT;

View File

@ -61,6 +61,11 @@ model Project {
id String @id @default(cuid()) id String @id @default(cuid())
title String title String
ownerId String ownerId String
// The theme assigned to this project (one of lib/themes' THEMES). Null =
// "Default Theme": follow whatever the user picked in the global theme
// menu. Applied only while the project's own page is open (see
// components/theme/project-theme-scope.tsx).
theme String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -1,4 +1,10 @@
import type { ThemeName } from "@/lib/themes";
export interface ProjectDTO { export interface ProjectDTO {
id: string; id: string;
title: string; title: string;
// The theme assigned to this project (see components/theme/). Null =
// "Default Theme": follow whatever the user picked in the global theme
// menu, like every other page.
theme: ThemeName | null;
} }