Compare commits

..

No commits in common. "a04e9a65bd0b3b9a5d5099e119965f155ad5252a" and "6f7b72c88295767b1a48b7498c04be83d82357cc" have entirely different histories.

19 changed files with 128 additions and 601 deletions

View File

@ -2,8 +2,6 @@ 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";
@ -20,19 +18,11 @@ 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.
// `theme` is stored as free text; the set of valid names is enforced const projects = await prisma.project.findMany({
// client-side (ProjectThemeSchema) so a plain assertion is safe here. where: { ownerId: session.user.id },
const projects: ProjectDTO[] = ( orderBy: { createdAt: "asc" },
await prisma.project.findMany({ select: { id: true, title: true },
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,9 +5,7 @@ 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,
@ -20,7 +18,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, theme: true }, select: { id: true, title: 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".
@ -32,28 +30,12 @@ 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
{scopedTheme && ( initialCategories={board}
<script dangerouslySetInnerHTML={{ __html: themeSwitchScript(scopedTheme) }} /> projectId={project.id}
)} title={project.title}
<ProjectThemeScope projectId={project.id} /> aiConfigured={aiConfigured}
<KanbanBoard />
initialCategories={board}
projectId={project.id}
title={project.title}
aiConfigured={aiConfigured}
/>
</>
); );
} }

View File

@ -980,34 +980,6 @@ html.theme-blueprint {
html.theme-blueprint [data-slot="button"] { html.theme-blueprint [data-slot="button"] {
position: relative; position: relative;
} }
/* Paper surfaces (dialogs, popovers, dropdown menus) carry cobalt ink:
they set `text-popover-foreground`, but the generic button and editor
styles also read `--foreground` / `--background` expecting the usual
light-theme semantics (dark ink, light surface). In blueprint those
tokens are board-level (near-white ink, cobalt surface), so ghost
buttons wash to white-on-white, outline buttons go blue-on-blue, and
the markdown editor's text is invisible on the sheet. Restoring the
paper semantics locally makes every descendant render correctly. */
html.theme-blueprint [data-slot="dialog-content"],
html.theme-blueprint [data-slot="popover-content"],
html.theme-blueprint [data-slot="dropdown-menu-content"] {
--foreground: var(--card-foreground);
--background: var(--card);
}
/* The sidebar and scheduled rail are cobalt board chrome with near-white
ink. The ghost buttons there (theme trigger, log out, collapse, rail
actions) reference `--muted` / `--foreground` for hover + expanded
states, which in blueprint are both paper-bright: a white-on-white
wash-out. Re-target those states to the sidebar's own accent pair:
a slightly lighter cobalt with the same near-white ink. */
html.theme-blueprint .bg-sidebar :is([data-slot="button"], [data-slot="dropdown-menu-trigger"]):hover,
html.theme-blueprint .bg-sidebar :is([data-slot="button"], [data-slot="dropdown-menu-trigger"]):focus-visible,
html.theme-blueprint .bg-sidebar :is([data-slot="button"], [data-slot="dropdown-menu-trigger"])[aria-expanded="true"] {
background-color: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
}
html.theme-blueprint [data-slot="button"]::before, html.theme-blueprint [data-slot="button"]::before,
html.theme-blueprint [data-slot="button"]::after { html.theme-blueprint [data-slot="button"]::after {
content: ""; content: "";
@ -1748,34 +1720,17 @@ html.theme-vaporwave .vw-palm--right-small {
that inline value (animations sit above inline styles in the cascade) -- that inline value (animations sit above inline styles in the cascade) --
the card stops following the pointer. opacity-50 is exactly the class the the card stops following the pointer. opacity-50 is exactly the class the
card gets while dragging, so the hiccup stands down for the duration. card gets while dragging, so the hiccup stands down for the duration.
(The tracking band sweeps inside a .vw-glitch-clip clip box rendered by (overflow: hidden keeps the tracking band clipped to the card while it
GroupCard rather than relying on overflow: hidden on the card itself -- sweeps; the hover lift uses the separate `translate` property, so the
the card must stay overflow-visible so the progress pie, which pokes jitter doesn't fight it.) */
outside the bottom-right corner, isn't cropped by the card or its
rounded-2xl corner. The hover lift uses the separate `translate`
property, so the jitter doesn't fight it.) */
html.theme-vaporwave [data-group-card]:not(.opacity-50) { html.theme-vaporwave [data-group-card]:not(.opacity-50) {
overflow: hidden;
animation: vw-card-glitch 10s linear infinite; animation: vw-card-glitch 10s linear infinite;
} }
html.theme-vaporwave [data-group-card]:not(.opacity-50) > div:first-child h3 { html.theme-vaporwave [data-group-card]:not(.opacity-50) > div:first-child h3 {
animation: vw-title-glitch 10s linear infinite; animation: vw-title-glitch 10s linear infinite;
} }
/* The band's clip box: a full-card overlay (in GroupCard) that carries the html.theme-vaporwave [data-group-card]:not(.opacity-50)::after {
overflow clipping, so the card itself never needs it. inset-0 +
border-radius: inherit matches the card's own padding-box clip exactly,
sweep included. Hidden in every other theme. */
.vw-glitch-clip {
display: none;
}
html.theme-vaporwave .vw-glitch-clip {
display: block;
position: absolute;
inset: 0;
overflow: hidden;
border-radius: inherit;
pointer-events: none;
}
html.theme-vaporwave [data-group-card]:not(.opacity-50) .vw-glitch-clip::after {
content: ""; content: "";
position: absolute; position: absolute;
left: 0; left: 0;
@ -1801,14 +1756,14 @@ html.theme-vaporwave [data-group-card]:not(.opacity-50) .vw-glitch-clip::after {
first delay elapses, and under reduced motion, everything just sits still.) */ first delay elapses, and under reduced motion, everything just sits still.) */
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50), html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50),
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50) > div:first-child h3, html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50) > div:first-child h3,
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50) .vw-glitch-clip::after { html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50)::after {
animation-duration: 13s; animation-duration: 13s;
animation-delay: 3s; animation-delay: 3s;
} }
/* Phase C: the longest period, offset furthest. */ /* Phase C: the longest period, offset furthest. */
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50), html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50),
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50) > div:first-child h3, html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50) > div:first-child h3,
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50) .vw-glitch-clip::after { html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50)::after {
animation-duration: 17s; animation-duration: 17s;
animation-delay: 7s; animation-delay: 7s;
} }

View File

@ -162,16 +162,7 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
// leaves enough clearance that a card's hover lift // leaves enough clearance that a card's hover lift
// (-translate-y-0.5 in GroupCard) doesn't tuck its top stroke // (-translate-y-0.5 in GroupCard) doesn't tuck its top stroke
// under the lane header above it. // under the lane header above it.
// "flex-1 space-y-2 overflow-y-auto rounded-t-lg px-2.5 pt-1.5 pb-2 transition-colors",
// overflow-x-clip: the lane is a fixed-width (w-72) panel whose
// cards always span it, so horizontal scrolling is never wanted.
// The only things that can spill horizontally are corner badges
// like the group card's progress pie, which deliberately poke
// past the card's right edge. `clip` (not `hidden`) keeps
// overflow-y-auto fully functional while simply discarding any
// spill, so a future badge that strays a px too far can never
// grow a stray horizontal scrollbar here.
"flex-1 space-y-2 overflow-y-auto overflow-x-clip rounded-t-lg px-2.5 pt-1.5 pb-2 transition-colors",
isDropTargetLane && "bg-primary/10" isDropTargetLane && "bg-primary/10"
)} )}
> >

View File

@ -406,11 +406,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<TooltipContent side="top">Archive (all to-dos done)</TooltipContent> <TooltipContent side="top">Archive (all to-dos done)</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{/* pr-2.5: the corner progress pie (TodoProgressPie) hugs <span className="ml-auto text-[11px] font-medium tabular-nums text-muted-foreground">
the card's right edge right here, so the label keeps a
little room of its own -- together the two clear each
other without either spilling past the lane's edge. */}
<span className="ml-auto pr-2.5 text-[11px] font-medium tabular-nums text-muted-foreground">
{completedCount}/{group.todos.length} done {completedCount}/{group.todos.length} done
</span> </span>
</div> </div>
@ -425,14 +421,6 @@ export function GroupCard({ group }: { group: GroupDTO }) {
)} )}
</> </>
)} )}
{/* Vaporwave's VHS tracking band sweeps inside this clip box (see
.vw-glitch-clip in app/globals.css) instead of the card itself
using overflow: hidden -- the card must stay overflow-visible so
the progress pie can poke past the bottom-right corner without
being clipped by it or the card's rounded corner. display:none
(and thus inert) in every other theme. */}
<div className="vw-glitch-clip" aria-hidden />
</div> </div>
{editingTodo && ( {editingTodo && (

View File

@ -23,7 +23,6 @@ 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";
@ -147,12 +146,7 @@ function Board({
<LayoutDashboard className="size-5" /> <LayoutDashboard className="size-5" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 items-center gap-2"> <h1 className="truncate font-heading text-xl font-bold leading-tight">{title}</h1>
<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

@ -40,20 +40,7 @@ export function TodoProgressPie({
// context (see hover:-translate-y-0.5 in GroupCard), so // context (see hover:-translate-y-0.5 in GroupCard), so
// without an explicit z-index here it paints on top and crops // without an explicit z-index here it paints on top and crops
// the badge's overlapping edge. // the badge's overlapping edge.
// -right-2.5: the badge's horizontal position is a budget, not className="absolute -right-1.5 -bottom-1.5 z-10 size-7 shrink-0 cursor-default rounded-full border-2 shadow-sm outline-none transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
// a taste call. From the card's right border there are exactly
// 10px of lane (CategoryLane's px-2.5) before the lane's own
// clip edge -- or its vertical scrollbar, in classic-scrollbar
// browsers, which sits at the same 10px mark and paints over
// anything past it. So the badge may stick out at most ~7px
// (10px offset from the card's *padding* edge minus the 3px
// border) to stay clear of both. In exchange the "N/N done"
// footer text carries pr-2.5, which hands this badge the room
// it needs to clear the text (see the span in GroupCard).
// Moving it further out looks like it "fits" until it doesn't:
// past the 10px mark the lane clips the badge's edge or grows
// a stray horizontal scrollbar.
className="absolute -right-2.5 -bottom-1.5 z-10 size-7 shrink-0 cursor-default rounded-full border-2 shadow-sm outline-none transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
style={{ style={{
borderColor: accentColor, borderColor: accentColor,
background: `conic-gradient(${accentColor} ${pct}%, ${emptyColor} ${pct}% 100%)`, background: `conic-gradient(${accentColor} ${pct}%, ${emptyColor} ${pct}% 100%)`,

View File

@ -1,145 +0,0 @@
"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,13 +3,11 @@
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 {
@ -17,10 +15,6 @@ 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);
@ -81,27 +75,8 @@ 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 <ProjectsContext.Provider value={{ projects, addProject, renameProject, removeProject }}>
value={{ projects, addProject, renameProject, removeProject, setProjectTheme }}
>
{children} {children}
</ProjectsContext.Provider> </ProjectsContext.Provider>
); );

View File

@ -1,37 +0,0 @@
"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,19 +11,6 @@ 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).
* *
@ -36,25 +23,73 @@ export type { ThemeName, RawTheme };
* *
* 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, always one of THEMES * - `resolvedTheme` the theme actually applied (system resolved via
* 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;
@ -63,21 +98,12 @@ 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 (
@ -97,25 +123,15 @@ export function resolveTheme(theme: RawTheme): ThemeName {
return resolve(theme); return resolve(theme);
} }
function applyToDom(name: ThemeName) { function applyToDom(theme: RawTheme) {
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 = themeColorScheme(name); root.style.colorScheme = DARK_SURFACES.includes(name) ? "dark" : "light";
}
/** 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({
@ -130,11 +146,6 @@ 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 {
@ -145,33 +156,21 @@ export function ThemeProvider({
} }
}, []); }, []);
// Pick up a project's no-FOUC marker (hard page load). Runs in the same // Keep the DOM in sync whenever the preference changes -- synchronously
// 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(resolvedTheme); applyToDom(theme);
}, [resolvedTheme]); }, [theme]);
// Follow the OS while in "system" mode (and nothing is scoped). // Follow the OS while in "system" mode.
useEffect(() => { useEffect(() => {
if (scope || theme !== "system" || !window.matchMedia) return; if (theme !== "system" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)"); const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyToDom(resolve("system")); const onChange = () => applyToDom("system");
mq.addEventListener("change", onChange); mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange); return () => mq.removeEventListener("change", onChange);
}, [scope, theme]); }, [theme]);
// Keep multiple tabs in sync. // Keep multiple tabs in sync.
useEffect(() => { useEffect(() => {
@ -192,25 +191,13 @@ export function ThemeProvider({
}, []); }, []);
const value = useMemo<ThemeContextValue>( const value = useMemo<ThemeContextValue>(
() => ({ theme, resolvedTheme, setTheme, themes: THEMES }), () => ({ theme, resolvedTheme: resolve(theme), setTheme, themes: THEMES }),
[theme, resolvedTheme, setTheme], [theme, setTheme],
);
const scopeValue = useMemo<ThemeScopeValue>(
() => ({ scope, setScope }),
[scope],
); );
return ( return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
<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,13 +31,12 @@ 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";
export type ThemeOption = { type ThemeOption = {
value: ThemeName; value: ThemeName;
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
@ -46,9 +45,8 @@ export 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. Exported for reuse by other // accent so the picker previews the vibe.
// theme pickers (e.g. components/projects/project-theme-picker.tsx). const LIGHT_OPTIONS: ThemeOption[] = [
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" },
@ -60,7 +58,7 @@ export 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" },
]; ];
export const DARK_OPTIONS: ThemeOption[] = [ 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" },
@ -71,9 +69,9 @@ export 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" },
]; ];
export const ALL_OPTIONS = [...LIGHT_OPTIONS, ...DARK_OPTIONS]; const ALL_OPTIONS = [...LIGHT_OPTIONS, ...DARK_OPTIONS];
export function OptionRow({ function OptionRow({
option, option,
active, active,
onSelect, onSelect,
@ -84,19 +82,20 @@ export function OptionRow({
}) { }) {
const Icon = option.icon; const Icon = option.icon;
return ( return (
<DropdownMenuItem <button
type="button"
onClick={onSelect} onClick={onSelect}
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer" 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"
> >
<Icon className="size-4 text-muted-foreground" /> <Icon className="size-4 text-muted-foreground group-focus:text-current" />
<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="size-4 shrink-0" />} {active && <Check className="absolute right-2 -mr-4 size-4" />}
</DropdownMenuItem> </button>
); );
} }
@ -145,9 +144,10 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
/> />
))} ))}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <button
type="button"
onClick={() => setTheme("system")} onClick={() => setTheme("system")}
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer" 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"
> >
<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="size-4 shrink-0" />} {isSystem && <Check className="absolute right-2 -mr-4 size-4" />}
</DropdownMenuItem> </button>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );

View File

@ -2,7 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTheme, DARK_SURFACES } from "./theme-provider"; import { useTheme, resolveTheme, 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,11 +24,9 @@ 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
* `resolvedTheme` -- the single source of truth for what's actually * theme state (the single source of truth), so the moment `setTheme` fires,
* applied, including a project's scoped theme (see theme-provider) -- so * every consumer re-renders with the new variant in the same commit -- no
* the moment `setTheme` fires (or a scope lifts), every consumer * stale card colors, no manual refresh needed.
* 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
@ -36,9 +34,9 @@ function useMounted(): boolean {
*/ */
export function isDarkTheme(): boolean { export function isDarkTheme(): boolean {
const mounted = useMounted(); const mounted = useMounted();
const { resolvedTheme } = useTheme(); const { theme } = useTheme();
if (!mounted) return false; if (!mounted) return false;
return DARK_SURFACES.includes(resolvedTheme); return DARK_SURFACES.includes(resolveTheme(theme));
} }
/** True when the "cyberpunk" theme is active. Used to swap the neon /** True when the "cyberpunk" theme is active. Used to swap the neon
@ -46,9 +44,9 @@ export function isDarkTheme(): boolean {
*/ */
export function isCyberpunkTheme(): boolean { export function isCyberpunkTheme(): boolean {
const mounted = useMounted(); const mounted = useMounted();
const { resolvedTheme } = useTheme(); const { theme } = useTheme();
if (!mounted) return false; if (!mounted) return false;
return resolvedTheme === "cyberpunk"; return resolveTheme(theme) === "cyberpunk";
} }
/** /**
@ -62,9 +60,9 @@ export function isCyberpunkTheme(): boolean {
*/ */
export function useGroupColor(key: string): GroupColor { export function useGroupColor(key: string): GroupColor {
const mounted = useMounted(); const mounted = useMounted();
const { resolvedTheme } = useTheme(); const { theme } = useTheme();
if (!mounted) return getGroupColor(key); if (!mounted) return getGroupColor(key);
return getThemedGroupColor(resolvedTheme, key); return getThemedGroupColor(resolveTheme(theme), 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, ProjectThemeSchema } from "@/lib/validation/project"; import { ProjectTitleSchema } 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,29 +17,7 @@ export async function createProject(title: string): Promise<ProjectDTO> {
}); });
revalidatePath("/projects"); revalidatePath("/projects");
return { id: project.id, title: project.title, theme: null }; return { id: project.id, title: project.title };
}
/**
* 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> {

View File

@ -1,96 +0,0 @@
/**
* 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,10 +1,3 @@
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

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

View File

@ -61,11 +61,6 @@ 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,10 +1,4 @@
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;
} }