From f66c7731d182a8d159bb0439edced8d360e5d804 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 29 Aug 2026 22:07:10 -0600 Subject: [PATCH] Add per-project theme assignment with scoped theming - Extract theme constants (THEMES, THEME_CLASSES, DARK_SURFACES) into a new framework-free lib/themes.ts so server and client code share one source of truth; re-export from theme-provider for backward compatibility. - Add nullable `theme` column to Project (Prisma migration + schema), plus ProjectThemeSchema validation and a setProjectTheme server action. - Introduce a ThemeScope context in the provider: a subtree can temporarily override the global preference via useThemeScope().setScope(name); the scoped theme wins for resolvedTheme/DOM while active, then lifts on cleanup. - Render an inline no-FOUC script (themeSwitchScript) on project pages that applies the theme class before first paint and leaves a data-project-theme marker the provider reads at hydration. - Add ProjectThemeScope component that sets/clears the scope from live ProjectsContext state, so picking a new theme updates instantly without waiting for the server re-render. - Add ProjectThemePicker dropdown (Default + light/dark options) rendered beside the project title in the kanban board header. - Wire optimistic setProjectTheme into ProjectsContext with rollback and error toast on failure. - Update use-dark-theme hooks to read resolvedTheme from context instead of resolving locally, so they reflect scoped themes correctly. --- app/(app)/layout.tsx | 20 ++- app/(app)/projects/[projectId]/page.tsx | 32 +++- components/board/kanban-board.tsx | 8 +- components/projects/project-theme-picker.tsx | 145 ++++++++++++++++ components/projects/projects-context.tsx | 27 ++- components/theme/project-theme-scope.tsx | 37 ++++ components/theme/theme-provider.tsx | 163 ++++++++++-------- components/theme/theme-toggle.tsx | 34 ++-- components/theme/use-dark-theme.ts | 22 +-- lib/actions/projects.ts | 26 ++- lib/themes.ts | 96 +++++++++++ lib/validation/project.ts | 7 + .../migration.sql | 2 + prisma/schema.prisma | 5 + types/project.ts | 6 + 15 files changed, 512 insertions(+), 118 deletions(-) create mode 100644 components/projects/project-theme-picker.tsx create mode 100644 components/theme/project-theme-scope.tsx create mode 100644 lib/themes.ts create mode 100644 prisma/migrations/20260830010434_add_project_theme/migration.sql diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index d080c78..4723097 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -2,6 +2,8 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; 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 { SideNav } from "@/components/nav/side-nav"; 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 // board (categories/groups/todos) is fetched separately by its own page. - const projects = await prisma.project.findMany({ - where: { ownerId: session.user.id }, - orderBy: { createdAt: "asc" }, - select: { id: true, title: true }, - }); + // `theme` is stored as free text; the set of valid names is enforced + // client-side (ProjectThemeSchema) so a plain assertion is safe here. + const projects: ProjectDTO[] = ( + 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 ( diff --git a/app/(app)/projects/[projectId]/page.tsx b/app/(app)/projects/[projectId]/page.tsx index 35e784d..489471f 100644 --- a/app/(app)/projects/[projectId]/page.tsx +++ b/app/(app)/projects/[projectId]/page.tsx @@ -5,7 +5,9 @@ import { prisma } from "@/lib/db"; import { projectAccessFilter } from "@/lib/access"; import { getBoard } from "@/lib/board"; import { getAiSettingsView } from "@/lib/ai-settings"; +import { themeSwitchScript, type ThemeName } from "@/lib/themes"; import { KanbanBoard } from "@/components/board/kanban-board"; +import { ProjectThemeScope } from "@/components/theme/project-theme-scope"; export default async function ProjectPage({ params, @@ -18,7 +20,7 @@ export default async function ProjectPage({ const project = await prisma.project.findFirst({ 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 // 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); + // 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 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 ( - + <> + {scopedTheme && ( +