From fca66959c2e50e2b3e0f4c17629ed528daff6a07 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 29 Aug 2026 18:29:10 -0600 Subject: [PATCH 1/3] Fix vaporwave glitch band clipping the progress pie badge - Move overflow:hidden off the group card onto a dedicated .vw-glitch-clip overlay so the card can stay overflow-visible and the corner progress pie is no longer cropped by the card or its rounded corner. - Add overflow-x-clip to CategoryLane so badges that poke past the card's right edge are discarded instead of spawning a stray horizontal scrollbar. - Nudge TodoProgressPie to -right-2.5 and give the "N/N done" label pr-2.5 so both clear each other within the lane's 10px margin without spilling. --- app/globals.css | 31 ++++++++++++++++++++------ components/board/category-lane.tsx | 11 ++++++++- components/board/group-card.tsx | 14 +++++++++++- components/board/todo-progress-pie.tsx | 15 ++++++++++++- 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/app/globals.css b/app/globals.css index 33a4cc9..e5ac6a0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1720,17 +1720,34 @@ html.theme-vaporwave .vw-palm--right-small { that inline value (animations sit above inline styles in the cascade) -- 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. - (overflow: hidden keeps the tracking band clipped to the card while it - sweeps; the hover lift uses the separate `translate` property, so the - jitter doesn't fight it.) */ + (The tracking band sweeps inside a .vw-glitch-clip clip box rendered by + GroupCard rather than relying on overflow: hidden on the card itself -- + the card must stay overflow-visible so the progress pie, which pokes + 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) { - overflow: hidden; animation: vw-card-glitch 10s linear infinite; } html.theme-vaporwave [data-group-card]:not(.opacity-50) > div:first-child h3 { animation: vw-title-glitch 10s linear infinite; } -html.theme-vaporwave [data-group-card]:not(.opacity-50)::after { +/* The band's clip box: a full-card overlay (in GroupCard) that carries the + 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: ""; position: absolute; left: 0; @@ -1756,14 +1773,14 @@ html.theme-vaporwave [data-group-card]:not(.opacity-50)::after { 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) > div:first-child h3, -html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50)::after { +html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50) .vw-glitch-clip::after { animation-duration: 13s; animation-delay: 3s; } /* 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) > div:first-child h3, -html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50)::after { +html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50) .vw-glitch-clip::after { animation-duration: 17s; animation-delay: 7s; } diff --git a/components/board/category-lane.tsx b/components/board/category-lane.tsx index fd2b2f6..f8fd4c5 100644 --- a/components/board/category-lane.tsx +++ b/components/board/category-lane.tsx @@ -162,7 +162,16 @@ export function CategoryLane({ category }: { category: CategoryDTO }) { // leaves enough clearance that a card's hover lift // (-translate-y-0.5 in GroupCard) doesn't tuck its top stroke // 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" )} > diff --git a/components/board/group-card.tsx b/components/board/group-card.tsx index 0a97b67..2cc8a8d 100644 --- a/components/board/group-card.tsx +++ b/components/board/group-card.tsx @@ -406,7 +406,11 @@ export function GroupCard({ group }: { group: GroupDTO }) { Archive (all to-dos done) )} - + {/* pr-2.5: the corner progress pie (TodoProgressPie) hugs + 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. */} + {completedCount}/{group.todos.length} done @@ -421,6 +425,14 @@ 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. */} +
{editingTodo && ( diff --git a/components/board/todo-progress-pie.tsx b/components/board/todo-progress-pie.tsx index 47b99ef..ee43a90 100644 --- a/components/board/todo-progress-pie.tsx +++ b/components/board/todo-progress-pie.tsx @@ -40,7 +40,20 @@ export function TodoProgressPie({ // context (see hover:-translate-y-0.5 in GroupCard), so // without an explicit z-index here it paints on top and crops // the badge's overlapping edge. - 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" + // -right-2.5: the badge's horizontal position is a budget, not + // 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={{ borderColor: accentColor, background: `conic-gradient(${accentColor} ${pct}%, ${emptyColor} ${pct}% 100%)`, From fed65f86e0cbf505e94ee75cde7c3bdfce3fcae3 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 29 Aug 2026 18:55:30 -0600 Subject: [PATCH 2/3] Fix blueprint theme contrast for paper surfaces and sidebar buttons - Dialogs, popovers, and dropdown menus were rendering ghost/outline buttons and editor text as white-on-white because they inherited the board-level cobalt tokens; restore card (paper) semantics locally. - Sidebar/rail ghost button hover, focus, and expanded states washed out to white-on-white; retarget them to the sidebar accent pair. --- app/globals.css | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/globals.css b/app/globals.css index e5ac6a0..f2892ce 100644 --- a/app/globals.css +++ b/app/globals.css @@ -980,6 +980,34 @@ html.theme-blueprint { html.theme-blueprint [data-slot="button"] { 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"]::after { content: ""; From f66c7731d182a8d159bb0439edced8d360e5d804 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 29 Aug 2026 22:07:10 -0600 Subject: [PATCH 3/3] 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 && ( +