Redesign UI theming, typography, and board polish #1

Merged
brianfertig merged 1 commits from UI-Overhaul-Test2 into main 2026-08-25 19:25:34 +00:00
33 changed files with 828 additions and 253 deletions
Showing only changes of commit b6b1690fdd - Show all commits

View File

@ -37,9 +37,9 @@ export default async function AdminPage() {
return ( return (
<div className="flex h-full flex-col gap-4 p-4"> <div className="flex h-full flex-col gap-4 p-4">
<div> <div className="px-1">
<h1 className="text-2xl font-bold">Admin</h1> <h1 className="font-heading text-xl font-bold tracking-tight">Admin</h1>
<p className="text-sm text-muted-foreground"> <p className="mt-0.5 text-[13px] text-muted-foreground">
Everyone with an account on this site. Click a role to change it. Everyone with an account on this site. Click a role to change it.
</p> </p>
</div> </div>

View File

@ -30,7 +30,11 @@ export default async function AppLayout({ children }: { children: React.ReactNod
<ProjectsProvider initialProjects={projects}> <ProjectsProvider initialProjects={projects}>
<BoardViewProvider> <BoardViewProvider>
<HoldOnCompleteProvider> <HoldOnCompleteProvider>
<div className="flex h-screen overflow-hidden"> {/* On small screens the three-column shell would crush the
board; give it a sensible minimum width and let the page
scroll horizontally instead, and drop the Scheduled panel
(auxiliary on a phone) until the viewport can hold it. */}
<div className="flex h-screen min-w-[860px] overflow-x-auto">
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} /> <SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
<main className="flex-1 overflow-auto">{children}</main> <main className="flex-1 overflow-auto">{children}</main>
<ScheduledPanel /> <ScheduledPanel />

View File

@ -1,7 +1,17 @@
import { NotebookPen } from "lucide-react";
export default function AuthLayout({ children }: { children: React.ReactNode }) { export default function AuthLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-muted/40 p-4"> <div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<div className="w-full max-w-sm">{children}</div> <div className="w-full max-w-sm">
<div className="mb-8 flex flex-col items-center gap-3">
<span className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-md">
<NotebookPen className="size-6" />
</span>
<span className="font-heading text-xl font-bold tracking-tight">Organize</span>
</div>
{children}
</div>
</div> </div>
); );
} }

View File

@ -4,14 +4,16 @@
@import "@uiw/react-md-editor/markdown-editor.css"; @import "@uiw/react-md-editor/markdown-editor.css";
@import "@uiw/react-markdown-preview/markdown.css"; @import "@uiw/react-markdown-preview/markdown.css";
@custom-variant dark (&:is(.dark *)); /* "dark:" must be true for every dark-surfaced theme, not just .dark --
Ocean is a second dark look, so both classes turn on dark utilities. */
@custom-variant dark (&:is(.dark *, .ocean *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-mono);
--font-heading: var(--font-sans); --font-heading: var(--font-heading);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@ -41,6 +43,10 @@
--color-popover: var(--popover); --color-popover: var(--popover);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card: var(--card); --color-card: var(--card);
/* One more surface: the "lane" behind a category's cards -- sits between
the page background and the card, so cards read as objects resting on a
tray instead of floating on the page. */
--color-lane: var(--lane);
--radius-sm: calc(var(--radius) * 0.6); --radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8); --radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius); --radius-lg: var(--radius);
@ -50,77 +56,173 @@
--radius-4xl: calc(var(--radius) * 2.6); --radius-4xl: calc(var(--radius) * 2.6);
} }
:root { /* ---------------------------------------------------------------------------
--background: oklch(1 0 0); Themes
--foreground: oklch(0.145 0 0); The app ships four looks, chosen via the theme picker (next-themes):
default -- calm, cool neutral light theme (the safe, focused one)
sunset -- warm paper + persimmon accent, a cozier light theme
dark -- deep charcoal with an indigo accent
ocean -- deep blue-green night theme with a seafoam accent
Each is a complete token set; components only ever use tokens, so adding
a fifth theme later is just another block like these.
--------------------------------------------------------------------------- */
:root,
.theme-default {
color-scheme: light;
--background: oklch(0.985 0.002 260);
--foreground: oklch(0.235 0.02 265);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.235 0.02 265);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.235 0.02 265);
/* Single calm accent (indigo/violet) for interactive chrome -- buttons, /* Single calm accent (indigo) for interactive chrome -- buttons, active
active nav, focus rings, links -- kept deliberately separate from the nav, focus rings -- kept deliberately separate from the curated vibrant
curated vibrant Group palette in lib/colors.ts so the group colors stay Group palette in lib/colors.ts so the group colors stay the only
the only "loud" color on screen. */ "loud" color on screen. */
--primary: oklch(0.53 0.24 274); --primary: oklch(0.53 0.2 272);
--primary-foreground: oklch(0.98 0.01 274); --primary-foreground: oklch(0.98 0.01 274);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.955 0.004 260);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.31 0.02 265);
--muted: oklch(0.97 0 0); --muted: oklch(0.95 0.004 260);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.52 0.02 265);
--accent: oklch(0.94 0.03 274); --accent: oklch(0.95 0.022 272);
--accent-foreground: oklch(0.33 0.16 274); --accent-foreground: oklch(0.35 0.12 272);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.58 0.21 27);
--border: oklch(0.922 0 0); --border: oklch(0.905 0.004 260);
--input: oklch(0.922 0 0); --input: oklch(0.885 0.004 260);
--ring: oklch(0.53 0.24 274); --ring: oklch(0.58 0.15 272);
--lane: oklch(0.95 0.005 260);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.269 0 0);
--radius: 0.625rem; --radius: 0.75rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.975 0.003 260);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.235 0.02 265);
--sidebar-primary: oklch(0.53 0.24 274); --sidebar-primary: oklch(0.53 0.2 272);
--sidebar-primary-foreground: oklch(0.98 0.01 274); --sidebar-primary-foreground: oklch(0.98 0.01 274);
--sidebar-accent: oklch(0.94 0.03 274); --sidebar-accent: oklch(0.95 0.022 272);
--sidebar-accent-foreground: oklch(0.33 0.16 274); --sidebar-accent-foreground: oklch(0.35 0.12 272);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.905 0.004 260);
--sidebar-ring: oklch(0.53 0.24 274); --sidebar-ring: oklch(0.58 0.15 272);
} }
.dark { .dark {
--background: oklch(0.145 0 0); color-scheme: dark;
--foreground: oklch(0.985 0 0); --background: oklch(0.175 0.012 265);
--card: oklch(0.205 0 0); --foreground: oklch(0.93 0.01 265);
--card-foreground: oklch(0.985 0 0); --card: oklch(0.22 0.013 265);
--popover: oklch(0.205 0 0); --card-foreground: oklch(0.93 0.01 265);
--popover-foreground: oklch(0.985 0 0); --popover: oklch(0.23 0.014 265);
--primary: oklch(0.73 0.17 276); --popover-foreground: oklch(0.93 0.01 265);
--primary-foreground: oklch(0.17 0.05 274); --primary: oklch(0.7 0.14 272);
--secondary: oklch(0.269 0 0); --primary-foreground: oklch(0.16 0.04 272);
--secondary-foreground: oklch(0.985 0 0); --secondary: oklch(0.27 0.012 265);
--muted: oklch(0.269 0 0); --secondary-foreground: oklch(0.93 0.01 265);
--muted-foreground: oklch(0.708 0 0); --muted: oklch(0.26 0.013 265);
--accent: oklch(0.3 0.08 274); --muted-foreground: oklch(0.68 0.02 265);
--accent-foreground: oklch(0.9 0.04 274); --accent: oklch(0.31 0.04 272);
--destructive: oklch(0.704 0.191 22.216); --accent-foreground: oklch(0.9 0.03 272);
--destructive: oklch(0.68 0.19 24);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 14%);
--ring: oklch(0.73 0.17 276); --ring: oklch(0.68 0.14 272);
--lane: oklch(1 0 0 / 5%);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0); --radius: 0.75rem;
--sidebar-foreground: oklch(0.985 0 0); --sidebar: oklch(0.205 0.013 265);
--sidebar-primary: oklch(0.73 0.17 276); --sidebar-foreground: oklch(0.93 0.01 265);
--sidebar-primary-foreground: oklch(0.17 0.05 274); --sidebar-primary: oklch(0.7 0.14 272);
--sidebar-accent: oklch(0.3 0.08 274); --sidebar-primary-foreground: oklch(0.16 0.04 272);
--sidebar-accent-foreground: oklch(0.9 0.04 274); --sidebar-accent: oklch(0.31 0.04 272);
--sidebar-accent-foreground: oklch(0.9 0.03 272);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.73 0.17 276); --sidebar-ring: oklch(0.68 0.14 272);
}
.theme-sunset {
color-scheme: light;
--background: oklch(0.968 0.016 75);
--foreground: oklch(0.27 0.032 52);
--card: oklch(0.988 0.008 80);
--card-foreground: oklch(0.27 0.032 52);
--popover: oklch(0.99 0.007 80);
--popover-foreground: oklch(0.27 0.032 52);
/* Persimmon -- warm enough to feel like part of the paper, saturated
enough to stay unmistakably "the accent". */
--primary: oklch(0.58 0.19 42);
--primary-foreground: oklch(0.98 0.01 60);
--secondary: oklch(0.945 0.02 72);
--secondary-foreground: oklch(0.32 0.05 50);
--muted: oklch(0.938 0.02 72);
--muted-foreground: oklch(0.54 0.045 58);
--accent: oklch(0.928 0.035 62);
--accent-foreground: oklch(0.36 0.11 44);
--destructive: oklch(0.57 0.21 25);
--border: oklch(0.89 0.022 72);
--input: oklch(0.865 0.024 72);
--ring: oklch(0.6 0.16 44);
--lane: oklch(0.942 0.018 72);
--chart-1: oklch(0.79 0.12 58);
--chart-2: oklch(0.66 0.14 48);
--chart-3: oklch(0.53 0.13 42);
--chart-4: oklch(0.42 0.1 52);
--chart-5: oklch(0.31 0.07 58);
/* Slightly rounder than the default theme -- a touch softer overall. */
--radius: 0.9rem;
--sidebar: oklch(0.958 0.013 75);
--sidebar-foreground: oklch(0.27 0.032 52);
--sidebar-primary: oklch(0.58 0.19 42);
--sidebar-primary-foreground: oklch(0.98 0.01 60);
--sidebar-accent: oklch(0.928 0.035 62);
--sidebar-accent-foreground: oklch(0.36 0.11 44);
--sidebar-border: oklch(0.89 0.022 72);
--sidebar-ring: oklch(0.6 0.16 44);
}
.ocean {
color-scheme: dark;
--background: oklch(0.2 0.035 224);
--foreground: oklch(0.92 0.02 215);
--card: oklch(0.245 0.04 220);
--card-foreground: oklch(0.92 0.02 215);
--popover: oklch(0.255 0.042 218);
--popover-foreground: oklch(0.92 0.02 215);
/* Seafoam -- a cool, luminous accent that reads "water at night" against
the deep blue-green surfaces without screaming. */
--primary: oklch(0.76 0.11 192);
--primary-foreground: oklch(0.21 0.05 200);
--secondary: oklch(0.295 0.042 222);
--secondary-foreground: oklch(0.92 0.02 215);
--muted: oklch(0.285 0.042 222);
--muted-foreground: oklch(0.7 0.03 215);
--accent: oklch(0.33 0.05 214);
--accent-foreground: oklch(0.9 0.03 192);
--destructive: oklch(0.7 0.17 25);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 14%);
--ring: oklch(0.72 0.11 192);
--lane: oklch(1 0 0 / 6%);
--chart-1: oklch(0.76 0.1 192);
--chart-2: oklch(0.63 0.11 200);
--chart-3: oklch(0.5 0.11 215);
--chart-4: oklch(0.4 0.1 228);
--chart-5: oklch(0.3 0.08 240);
--radius: 0.8rem;
--sidebar: oklch(0.225 0.042 220);
--sidebar-foreground: oklch(0.92 0.02 215);
--sidebar-primary: oklch(0.76 0.11 192);
--sidebar-primary-foreground: oklch(0.21 0.05 200);
--sidebar-accent: oklch(0.33 0.05 214);
--sidebar-accent-foreground: oklch(0.9 0.03 192);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.72 0.11 192);
} }
@layer base { @layer base {
@ -129,8 +231,117 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
/* Gentle, calm type for a tool that sits on screen all day. */
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
letter-spacing: -0.006em;
} }
html { /* Headings use the display face (see --font-heading / Figtree). */
@apply font-sans; h1,
h2,
h3,
h4 {
letter-spacing: -0.018em;
}
/* Smooth, low-key hover/selection feel: fade color changes instead of
snapping. Components that set their own transition-* utility override
this (utilities win on specificity). */
*,
*::before,
*::after {
transition:
background-color 200ms ease,
border-color 200ms ease,
color 200ms ease,
fill 200ms ease,
stroke 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition: none !important;
animation: none !important;
}
}
::selection {
background: color-mix(in srgb, var(--ring) 28%, transparent);
}
/* Quiet scrollbars that match the theme. */
* {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--foreground) 22%, transparent) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track,
*::-webkit-scrollbar-corner {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--foreground) 20%, transparent);
border: 3px solid transparent;
border-radius: 999px;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--foreground) 32%, transparent);
background-clip: padding-box;
} }
} }
/* ---------------------------------------------------------------------------
Markdown editor / preview (third-party CSS) -- fit the theme's tokens and
the app's type scale so notes blend into dialogs instead of looking
pasted in.
--------------------------------------------------------------------------- */
.wmde-markdown {
font-family: var(--font-sans);
font-size: 0.875rem;
letter-spacing: -0.006em;
}
.w-md-editor {
--md-editor-font-family: var(--font-sans);
font-size: 0.875rem;
color: var(--foreground);
background: var(--card);
border-color: var(--border);
box-shadow: none;
}
.w-md-editor .w-md-editor-toolbar {
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border-color: var(--border);
}
.w-md-editor-toolbar-item,
.w-md-editor-toolbar-item-icon {
color: var(--muted-foreground);
}
.w-md-editor-toolbar-item-active,
.w-md-editor-toolbar-item-active .w-md-editor-toolbar-item-icon {
color: var(--foreground);
}
.w-md-editor-text,
.w-md-editor-text-pre,
.w-md-editor-text-input {
color: var(--foreground);
}
.w-md-editor-text-input::placeholder {
color: var(--muted-foreground);
}
/* Dark-surfaced themes (Dark, Ocean) -- the editor's stock CSS is
light-only, so override it where we render with data-color-mode="dark". */
[data-color-mode="dark"] .w-md-editor {
background: var(--card);
border-color: var(--border);
}
[data-color-mode="dark"] .w-md-editor .w-md-editor-toolbar {
background: color-mix(in srgb, var(--foreground) 7%, transparent);
border-color: var(--border);
}
[data-color-mode="dark"] .w-md-editor .w-md-editor-text,
[data-color-mode="dark"] .w-md-editor .w-md-editor-text-pre,
[data-color-mode="dark"] .w-md-editor .w-md-editor-text-input {
color: var(--foreground);
background: transparent;
}

View File

@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Figtree, Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/components/theme/theme-provider"; import { ThemeProvider } from "@/components/theme/theme-provider";
@ -7,13 +7,23 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { RegisterServiceWorker } from "@/components/pwa/register-sw"; import { RegisterServiceWorker } from "@/components/pwa/register-sw";
const geistSans = Geist({ /** UI type: Inter -- a neutral, highly legible humanist sans that reads
variable: "--font-geist-sans", * cleanly at small sizes (to-do lists, tables, nav). */
const inter = Inter({
variable: "--font-sans",
subsets: ["latin"], subsets: ["latin"],
}); });
const geistMono = Geist_Mono({ /** Display type: Figtree -- a slightly friendlier, rounder geometric sans
variable: "--font-geist-mono", * for headings and wordmarks. Same family, one step of personality. */
const figtree = Figtree({
variable: "--font-heading",
subsets: ["latin"],
});
/** Code: JetBrains Mono, used for the markdown editor's monospace bits. */
const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"], subsets: ["latin"],
}); });
@ -34,8 +44,8 @@ export const metadata: Metadata = {
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: [ themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" }, { media: "(prefers-color-scheme: light)", color: "#fafafa" },
{ media: "(prefers-color-scheme: dark)", color: "#171717" }, { media: "(prefers-color-scheme: dark)", color: "#22252b" },
], ],
}; };
@ -43,11 +53,21 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
return ( return (
<html <html
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${inter.variable} ${figtree.variable} ${jetbrainsMono.variable} h-full antialiased`}
suppressHydrationWarning suppressHydrationWarning
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> {/* Four looks: Default (calm light), Sunset (warm light), Dark, Ocean
(deep blue-green) -- complete token sets in app/globals.css.
"system" resolves to Default/Dark by OS preference.
No-FOUC theme restore: runs before first paint, applies the
stored preference to <html> (class + color-scheme). */}
<script
dangerouslySetInnerHTML={{
__html: `!(function(){try{var r=document.documentElement,c=['theme-default','theme-sunset','dark','ocean','light'];for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}var v=null;try{v=localStorage.getItem('theme');}catch(e){}var m={default:'theme-default',sunset:'theme-sunset',dark:'dark',ocean:'ocean'};if(v==='system'||!v){v=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'default';}if(m[v]){r.classList.add(m[v]);r.style.colorScheme=(v==='dark'||v==='ocean')?'dark':'light';}}catch(e){}})();`,
}}
/>
<ThemeProvider defaultTheme="system">
<TooltipProvider delay={200}> <TooltipProvider delay={200}>
{children} {children}
<Toaster /> <Toaster />

View File

@ -49,12 +49,21 @@ export function UserRoleMenu({ userId, role }: { userId: string; role: Role }) {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Badge // Base UI requires a native <button> trigger -- so the Badge
variant={ROLE_BADGE_VARIANT[current]} // (a <span>) sits inside a bare reset button instead of being
className="cursor-pointer select-none" // the trigger itself.
<button
type="button"
className="inline-flex appearance-none border-0 bg-transparent p-0"
aria-label={`Change role (currently ${ROLE_LABEL[current]})`}
> >
{ROLE_LABEL[current]} <Badge
</Badge> variant={ROLE_BADGE_VARIANT[current]}
className="cursor-pointer select-none"
>
{ROLE_LABEL[current]}
</Badge>
</button>
} }
/> />
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">

View File

@ -38,11 +38,13 @@ export function AddCategoryLane() {
<DialogTrigger <DialogTrigger
render={ render={
<button <button
className="flex h-32 w-72 shrink-0 flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border text-muted-foreground transition-colors hover:border-primary hover:text-primary" className="flex h-full min-h-32 w-72 shrink-0 flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border/80 text-muted-foreground transition-colors hover:border-primary/60 hover:bg-primary/5 hover:text-primary"
aria-label="Add category" aria-label="Add category"
> >
<Plus className="size-6" /> <span className="flex size-9 items-center justify-center rounded-full bg-foreground/5">
<span className="text-sm font-medium">Add Category</span> <Plus className="size-5" />
</span>
<span className="text-sm font-medium">Add lane</span>
</button> </button>
} }
/> />

View File

@ -49,7 +49,10 @@ export function AddGroupPopover({ categoryId }: { categoryId: string }) {
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger <PopoverTrigger
render={ render={
<Button variant="ghost" className="w-full justify-start gap-2 text-muted-foreground"> <Button
variant="ghost"
className="w-full justify-start gap-2 rounded-lg text-muted-foreground/90 hover:text-foreground"
>
<Plus className="size-4" /> <Plus className="size-4" />
Group Group
</Button> </Button>

View File

@ -5,7 +5,7 @@ import { useSortable } from "@dnd-kit/sortable";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useDndContext, useDroppable } from "@dnd-kit/core"; import { useDndContext, useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { GripVertical, MoreVertical, Trash2 } from "lucide-react"; import { GripVertical, MoreVertical, Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@ -86,6 +86,12 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
}); });
const groupIds = visibleGroups.map((g) => g.id); const groupIds = visibleGroups.map((g) => g.id);
// Small open-count for the header -- "what's still to do in this lane"
// at a glance, the number a user organizing their day actually wants.
const openTodos = category.groups.reduce(
(n, g) => n + g.todos.filter((t) => !t.completed).length,
0
);
return ( return (
<> <>
@ -93,21 +99,34 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
ref={setNodeRef} ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }} style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn( className={cn(
"flex h-full w-72 shrink-0 flex-col rounded-xl bg-muted/40", "flex h-full w-72 shrink-0 flex-col overflow-hidden rounded-2xl border bg-lane",
isDragging && "opacity-50" isDragging && "opacity-50"
)} )}
> >
<div className="flex items-center gap-1 p-3"> <div className="flex items-center gap-1.5 px-3 pt-3 pb-2">
<button <button
ref={setActivatorNodeRef} ref={setActivatorNodeRef}
{...attributes} {...attributes}
{...listeners} {...listeners}
className="cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing" className="-ml-1 cursor-grab touch-none rounded-md p-1 text-muted-foreground/70 hover:bg-accent hover:text-foreground active:cursor-grabbing"
aria-label={`Drag to reorder ${category.name}`} aria-label={`Drag to reorder ${category.name}`}
> >
<GripVertical className="size-4" /> <GripVertical className="size-3.5" />
</button> </button>
<h2 className="min-w-0 flex-1 truncate font-semibold">{category.name}</h2> <h2 className="min-w-0 flex-1 truncate font-heading text-[15px] font-semibold tracking-tight">
{category.name}
</h2>
<span
className={cn(
"flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
openTodos > 0
? "bg-foreground/8 text-foreground"
: "bg-foreground/5 text-muted-foreground"
)}
aria-label={`${openTodos} open to-dos in ${category.name}`}
>
{openTodos}
</span>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
@ -139,9 +158,8 @@ 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-lg px-3 pt-2 pb-2 transition-colors", "flex-1 space-y-2 overflow-y-auto rounded-t-lg px-2.5 pt-1.5 pb-2 transition-colors",
isDropTargetLane && isDropTargetLane && "bg-primary/10"
"bg-primary/10 outline-2 outline-dashed outline-primary/50 -outline-offset-2"
)} )}
> >
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}> <SortableContext items={groupIds} strategy={verticalListSortingStrategy}>

View File

@ -1,10 +1,10 @@
"use client"; "use client";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { GROUP_COLORS, type GroupColorKey } from "@/lib/colors"; import { GROUP_COLORS, type GroupColorKey } from "@/lib/colors";
import { isDarkTheme } from "@/components/theme/use-dark-theme";
export function ColorSwatchPicker({ export function ColorSwatchPicker({
value, value,
@ -13,8 +13,7 @@ export function ColorSwatchPicker({
value: GroupColorKey; value: GroupColorKey;
onChange: (key: GroupColorKey) => void; onChange: (key: GroupColorKey) => void;
}) { }) {
const { resolvedTheme } = useTheme(); const isDark = isDarkTheme();
const isDark = resolvedTheme === "dark";
return ( return (
<div role="radiogroup" aria-label="Card color" className="flex flex-wrap gap-2"> <div role="radiogroup" aria-label="Card color" className="flex flex-wrap gap-2">

View File

@ -2,15 +2,17 @@ import { LayoutDashboard } from "lucide-react";
export function EmptyState() { export function EmptyState() {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-12 text-center"> <div className="flex flex-1 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border/80 p-10 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<LayoutDashboard className="size-8" /> <LayoutDashboard className="size-7" />
</div>
<div className="space-y-1">
<h2 className="font-heading text-lg font-semibold">Your board is empty</h2>
<p className="mx-auto max-w-xs text-sm leading-relaxed text-muted-foreground">
Lanes hold your groups of notes and to-dos. Add your first lane to
start organizing.
</p>
</div> </div>
<h2 className="text-lg font-semibold">Your board is empty</h2>
<p className="max-w-sm text-sm text-muted-foreground">
Create your first category to get started categories are the lanes that hold your
groups of notes and to-dos.
</p>
</div> </div>
); );
} }

View File

@ -1,11 +1,11 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useTheme } from "next-themes";
import { toast } from "sonner"; import { toast } from "sonner";
import { Mic, Send, Sparkles, Square } from "lucide-react"; import { Mic, Send, Sparkles, Square } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
@ -31,8 +31,7 @@ export function GroupAiDialog({
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const { saveNote } = useBoard(); const { saveNote } = useBoard();
const { resolvedTheme } = useTheme(); const colorMode = colorModeFromTheme();
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
const [messages, setMessages] = useState<GroupAiMessage[]>([]); const [messages, setMessages] = useState<GroupAiMessage[]>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");

View File

@ -1,9 +1,9 @@
"use client"; "use client";
import { useTheme } from "next-themes";
import { NotebookPen } from "lucide-react"; import { NotebookPen } from "lucide-react";
import { getGroupColor } from "@/lib/colors"; import { getGroupColor } from "@/lib/colors";
import { isDarkTheme } from "@/components/theme/use-dark-theme";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { GroupDTO } from "@/types/board"; import type { GroupDTO } from "@/types/board";
@ -14,18 +14,15 @@ import type { GroupDTO } from "@/types/board";
* would register a second useSortable/useDraggable for the same id. * would register a second useSortable/useDraggable for the same id.
*/ */
export function GroupCardOverlay({ group }: { group: GroupDTO }) { export function GroupCardOverlay({ group }: { group: GroupDTO }) {
const { resolvedTheme } = useTheme(); const isDark = isDarkTheme();
const color = getGroupColor(group.color); const color = getGroupColor(group.color);
const isDark = resolvedTheme === "dark";
const borderColor = isDark ? color.dark : color.light; const borderColor = isDark ? color.dark : color.light;
const backgroundColor = isDark const backgroundColor = `color-mix(in srgb, ${isDark ? color.softDark : color.soft} 85%, var(--card))`;
? `color-mix(in srgb, ${color.dark} 18%, var(--card))`
: `color-mix(in srgb, ${color.light} 12%, var(--card))`;
return ( return (
<div <div
style={{ borderColor, backgroundColor }} style={{ borderColor, backgroundColor }}
className={cn("rotate-1 scale-105 cursor-grabbing rounded-xl border-[3px] p-3 shadow-lg")} className={cn("rotate-1 scale-105 cursor-grabbing rounded-2xl border-[3px] p-3 shadow-lg")}
> >
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1">
<h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3> <h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3>

View File

@ -3,7 +3,6 @@
import { useState } from "react"; import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { useTheme } from "next-themes";
import { import {
Archive, Archive,
ChevronRight, ChevronRight,
@ -19,6 +18,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TodoCheckbox } from "@/components/board/todo-checkbox"; import { TodoCheckbox } from "@/components/board/todo-checkbox";
import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip"; import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip";
import { import {
@ -29,6 +29,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors"; import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors";
import { isDarkTheme } from "@/components/theme/use-dark-theme";
import { useBoard } from "@/components/board/board-context"; import { useBoard } from "@/components/board/board-context";
import { useBoardView } from "@/components/board/board-view-provider"; import { useBoardView } from "@/components/board/board-view-provider";
import { useHoldOnComplete } from "@/components/hold-on-complete"; import { useHoldOnComplete } from "@/components/hold-on-complete";
@ -90,7 +91,9 @@ export function GroupCard({ group }: { group: GroupDTO }) {
const { view } = useBoardView(); const { view } = useBoardView();
const { holds, beginHold, cancelHold } = useHoldOnComplete(); const { holds, beginHold, cancelHold } = useHoldOnComplete();
const compact = view === "compact"; const compact = view === "compact";
const { resolvedTheme } = useTheme(); // True for any theme whose surfaces are dark -- Dark and Ocean both use
// their `dark` color variant (see lib/colors.ts); light themes use `light`.
const isDark = isDarkTheme();
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null); const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
@ -119,15 +122,14 @@ export function GroupCard({ group }: { group: GroupDTO }) {
const isDropTarget = isOver && !isDragging; const isDropTarget = isOver && !isDragging;
const color = getGroupColor(group.color); const color = getGroupColor(group.color);
const isDark = resolvedTheme === "dark";
const borderColor = isDark ? color.dark : color.light; const borderColor = isDark ? color.dark : color.light;
// A subdued tint of the same stroke color, blended into the theme's own // The card fill is a calm, hand-picked tint of the same stroke color
// card surface -- not a fixed pastel, so it automatically stays correct // (`soft` / `softDark` in lib/colors.ts), blended a little into the theme's
// if the neutral card token ever changes, and needs no separate // own card surface so it sits naturally on any theme -- the 85% weight
// light/dark background palette to hand-tune. // keeps the tint in charge while still letting a tinted theme surface show
const backgroundColor = isDark // through. Dark themes use `softDark`: the same hue at a dark-surface
? `color-mix(in srgb, ${color.dark} 18%, var(--card))` // lightness, instead of the near-white pastel of `soft`.
: `color-mix(in srgb, ${color.light} 12%, var(--card))`; const backgroundColor = `color-mix(in srgb, ${isDark ? color.softDark : color.soft} 85%, var(--card))`;
// Opposing hue from the group's own color, for the progress pie below -- // Opposing hue from the group's own color, for the progress pie below --
// so it reads as a distinct accent rather than blending into the border. // so it reads as a distinct accent rather than blending into the border.
const pieAccentColor = getComplementaryColor( const pieAccentColor = getComplementaryColor(
@ -191,7 +193,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
backgroundColor, backgroundColor,
}} }}
className={cn( className={cn(
"relative rounded-xl border-[3px] p-3 shadow-sm transition-shadow", "relative rounded-2xl border-[3px] p-3 shadow-sm transition-shadow",
"hover:-translate-y-0.5 hover:shadow-md", "hover:-translate-y-0.5 hover:shadow-md",
isDragging && "opacity-50 shadow-lg", isDragging && "opacity-50 shadow-lg",
isDropTarget && "ring-2 ring-primary ring-offset-2 ring-offset-background" isDropTarget && "ring-2 ring-primary ring-offset-2 ring-offset-background"
@ -225,7 +227,9 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<GripVertical className="size-4" /> <GripVertical className="size-4" />
</span> </span>
)} )}
<h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3> <h3 className="min-w-0 flex-1 truncate pt-1 font-heading text-[15px] font-semibold tracking-tight">
{group.title}
</h3>
</div> </div>
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
<NotesDialog group={group} /> <NotesDialog group={group} />
@ -263,7 +267,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
{!isCollapsed && ( {!isCollapsed && (
<> <>
<ul className="mt-1 space-y-1"> <ul className="mt-2 space-y-0.5">
{visibleTodos.map((todo, index) => { {visibleTodos.map((todo, index) => {
const todoButton = ( const todoButton = (
<button <button
@ -271,8 +275,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
onClick={() => setEditingTodo(todo)} onClick={() => setEditingTodo(todo)}
style={todo.completed ? undefined : { color: todoTextColor }} style={todo.completed ? undefined : { color: todoTextColor }}
className={cn( className={cn(
"flex min-w-0 flex-1 items-center gap-1 text-left text-sm hover:underline", "-mx-1 flex min-w-0 flex-1 items-center gap-1 rounded-md px-1 text-left text-sm transition-colors hover:bg-foreground/5",
todo.completed && "text-muted-foreground line-through" todo.completed && "text-muted-foreground line-through hover:bg-transparent"
)} )}
> >
<span className="min-w-0 truncate">{todo.title}</span> <span className="min-w-0 truncate">{todo.title}</span>
@ -361,7 +365,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
) )
) : ( ) : (
<> <>
<div className="mt-1"> <div className="mt-2">
<TodoCreatePopover groupId={group.id} categoryId={group.categoryId} /> <TodoCreatePopover groupId={group.id} categoryId={group.categoryId} />
</div> </div>
@ -377,16 +381,30 @@ export function GroupCard({ group }: { group: GroupDTO }) {
</Button> </Button>
)} )}
{canArchive && ( {(canArchive || group.todos.length > 0) && (
<Button <div className="mt-1 flex items-center gap-1 border-t border-foreground/[0.07] pt-1.5">
variant="ghost" {canArchive && (
size="sm" <Tooltip>
className="w-full justify-start gap-2 text-muted-foreground" <TooltipTrigger
onClick={() => archiveGroup(group.id, group.categoryId)} render={
> <Button
<Archive className="size-3.5" /> variant="ghost"
Archive size="icon-sm"
</Button> className="shrink-0 text-muted-foreground"
onClick={() => archiveGroup(group.id, group.categoryId)}
aria-label="Archive group"
>
<Archive className="size-3.5" />
</Button>
}
/>
<TooltipContent side="top">Archive (all to-dos done)</TooltipContent>
</Tooltip>
)}
<span className="ml-auto text-[11px] font-medium tabular-nums text-muted-foreground">
{completedCount}/{group.todos.length} done
</span>
</div>
)} )}
<TodoProgressPie <TodoProgressPie

View File

@ -13,6 +13,7 @@ import {
type DragStartEvent, type DragStartEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable"; import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
import { LayoutDashboard, Plus } from "lucide-react";
import { BoardProvider, useBoard } from "@/components/board/board-context"; import { BoardProvider, useBoard } from "@/components/board/board-context";
import { CategoryLane } from "@/components/board/category-lane"; import { CategoryLane } from "@/components/board/category-lane";
@ -20,11 +21,21 @@ import { AddCategoryLane } from "@/components/board/add-category-lane";
import { GroupCardOverlay } from "@/components/board/group-card-overlay"; import { GroupCardOverlay } from "@/components/board/group-card-overlay";
import { EmptyState } from "@/components/board/empty-state"; import { EmptyState } from "@/components/board/empty-state";
import { ViewSwitcher } from "@/components/board/view-switcher"; import { ViewSwitcher } from "@/components/board/view-switcher";
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { CategoryDTO, GroupDTO } from "@/types/board"; import type { CategoryDTO, GroupDTO } from "@/types/board";
function Board({ title }: { title: string }) { function Board({ title }: { title: string }) {
const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard(); const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null); const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
const [quickAddOpen, setQuickAddOpen] = useState(false);
// The "+ to-do" quick action targets the first group it can find -- the
// goal is one click from a keyboard or pointer to start typing a to-do
// somewhere sensible, not a journey through lanes and cards.
const quickTargetCategory = categories.find((c) => c.groups.length > 0);
const quickTargetGroup = quickTargetCategory?.groups[0];
const sensors = useSensors( const sensors = useSensors(
// A short activation distance lets a plain click (checkbox, notes icon, // A short activation distance lets a plain click (checkbox, notes icon,
@ -110,15 +121,57 @@ function Board({ title }: { title: string }) {
return ( return (
<DndContext <DndContext
id="board-dnd"
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="flex h-full flex-col gap-4 p-4"> <div className="flex h-full flex-col gap-4 p-4">
<div className="flex items-center justify-between gap-2 px-1"> <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-1">
<h1 className="truncate text-2xl font-bold">{title}</h1> <div className="flex min-w-0 items-center gap-3">
<ViewSwitcher /> <span className="hidden size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:flex">
<LayoutDashboard className="size-5" />
</span>
<div className="min-w-0">
<h1 className="truncate font-heading text-xl font-bold leading-tight">{title}</h1>
<p className="truncate text-[13px] text-muted-foreground">
{categories.length === 0
? "Add a lane to get started"
: `${categories.length} ${categories.length === 1 ? "lane" : "lanes"} · ${categories.reduce(
(n, c) => n + c.groups.length,
0
)} groups · ${categories.reduce(
(n, c) =>
n +
c.groups.reduce((m, g) => m + g.todos.filter((t) => !t.completed).length, 0),
0
)} open to-dos`}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={!quickTargetGroup}
onClick={() => setQuickAddOpen(true)}
>
<Plus className="size-3.5" />
To-do
</Button>
}
/>
<TooltipContent side="bottom">
{quickTargetGroup ? "Quickly add a to-do to the first group" : "Add a group first, then quickly add to-dos"}
</TooltipContent>
</Tooltip>
<ViewSwitcher />
</div>
</div> </div>
{categories.length === 0 ? ( {categories.length === 0 ? (
@ -139,6 +192,15 @@ function Board({ title }: { title: string }) {
</div> </div>
<DragOverlay>{draggedGroup ? <GroupCardOverlay group={draggedGroup} /> : null}</DragOverlay> <DragOverlay>{draggedGroup ? <GroupCardOverlay group={draggedGroup} /> : null}</DragOverlay>
{quickTargetCategory && quickTargetGroup && (
<TodoCreateDialog
groupId={quickTargetGroup.id}
categoryId={quickTargetCategory.id}
open={quickAddOpen}
onOpenChange={setQuickAddOpen}
/>
)}
</DndContext> </DndContext>
); );
} }

View File

@ -2,9 +2,9 @@
import { useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTheme } from "next-themes";
import { MarkdownPreview } from "@/components/markdown/markdown-widgets"; import { MarkdownPreview } from "@/components/markdown/markdown-widgets";
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
const OFFSET = 16; const OFFSET = 16;
// Rough size assumptions used to keep the tooltip on-screen -- cheaper than // Rough size assumptions used to keep the tooltip on-screen -- cheaper than
@ -29,8 +29,7 @@ export function MouseFollowTooltip({
content: string; content: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { resolvedTheme } = useTheme(); const colorMode = colorModeFromTheme();
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
const [pos, setPos] = useState<{ x: number; y: number } | null>(null); const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
function handleMove(e: React.MouseEvent) { function handleMove(e: React.MouseEvent) {

View File

@ -1,10 +1,10 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { NotebookPen, Pencil, Sparkles } from "lucide-react"; import { NotebookPen, Pencil, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -19,8 +19,7 @@ import type { GroupDTO } from "@/types/board";
export function NotesDialog({ group }: { group: GroupDTO }) { export function NotesDialog({ group }: { group: GroupDTO }) {
const { saveNote } = useBoard(); const { saveNote } = useBoard();
const { resolvedTheme } = useTheme(); const colorMode = colorModeFromTheme();
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);

View File

@ -1,10 +1,11 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { toast } from "sonner"; import { toast } from "sonner";
import { Check, Copy, Sparkles } from "lucide-react"; import { Check, Copy, Sparkles } from "lucide-react";
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -30,8 +31,7 @@ export function StatusUpdateDialog({
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const { resolvedTheme } = useTheme(); const colorMode = colorModeFromTheme();
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
// The generated summary, shown permanently once produced -- there's no // The generated summary, shown permanently once produced -- there's no
// accept/discard step, since nothing here is ever saved back to the // accept/discard step, since nothing here is ever saved back to the

View File

@ -1,9 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -28,8 +28,7 @@ export function TodoCreateDialog({
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const { addTodo } = useBoard(); const { addTodo } = useBoard();
const { resolvedTheme } = useTheme(); const colorMode = colorModeFromTheme();
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [details, setDetails] = useState(""); const [details, setDetails] = useState("");

View File

@ -1,10 +1,10 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useTheme } from "next-themes";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -21,8 +21,7 @@ export function TodoCreatePopover({
categoryId: string; categoryId: string;
}) { }) {
const { addTodo } = useBoard(); const { addTodo } = useBoard();
const { resolvedTheme } = useTheme(); const colorMode = colorModeFromTheme();
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
@ -50,7 +49,7 @@ export function TodoCreatePopover({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="w-full justify-start gap-2 text-muted-foreground" className="w-full justify-start gap-2 text-muted-foreground/90 hover:text-foreground"
> >
<Plus className="size-3.5" /> <Plus className="size-3.5" />
Add to-do Add to-do

View File

@ -1,10 +1,10 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useTheme } from "next-themes";
import { Pencil, Trash2 } from "lucide-react"; import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@ -35,8 +35,7 @@ export function TodoEditDialog({
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const { editTodo, removeTodo } = useBoard(); const { editTodo, removeTodo } = useBoard();
const { resolvedTheme } = useTheme(); const colorMode = colorModeFromTheme();
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
const [title, setTitle] = useState(todo.title); const [title, setTitle] = useState(todo.title);
const [details, setDetails] = useState(todo.details ?? ""); const [details, setDetails] = useState(todo.details ?? "");

View File

@ -1,16 +1,15 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useTheme } from "next-themes";
import { MessageCircle } from "lucide-react"; import { MessageCircle } from "lucide-react";
import { ChatMessageList, ChatComposer, type ChatBubbleMessage } from "@/components/ai/chat-thread"; import { ChatMessageList, ChatComposer, type ChatBubbleMessage } from "@/components/ai/chat-thread";
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
import { useSpeechRecognition } from "@/hooks/use-speech-recognition"; import { useSpeechRecognition } from "@/hooks/use-speech-recognition";
import { sendChatMessage, type ChatMessage } from "@/lib/actions/chat-ai"; import { sendChatMessage, type ChatMessage } from "@/lib/actions/chat-ai";
export function ChatPageClient({ aiConfigured }: { aiConfigured: boolean }) { export function ChatPageClient({ aiConfigured }: { aiConfigured: boolean }) {
const { resolvedTheme } = useTheme(); const colorMode = colorModeFromTheme();
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
@ -45,15 +44,17 @@ export function ChatPageClient({ aiConfigured }: { aiConfigured: boolean }) {
return ( return (
<div className="mx-auto flex h-full max-w-2xl flex-col gap-4 p-4"> <div className="mx-auto flex h-full max-w-2xl flex-col gap-4 p-4">
<div> <div className="flex items-center gap-3 px-1">
<h1 className="flex items-center gap-2 text-2xl font-bold"> <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<MessageCircle className="size-6 text-primary" /> <MessageCircle className="size-5" />
Chat </span>
</h1> <div className="min-w-0">
<p className="text-sm text-muted-foreground"> <h1 className="truncate font-heading text-xl font-bold tracking-tight">Chat</h1>
Ask about anything across your boards notes, open to-dos, and things you&apos;ve <p className="truncate text-[13px] text-muted-foreground">
already finished. Ask about anything across your boards notes, open to-dos, and things you&apos;ve
</p> already finished.
</p>
</div>
</div> </div>
{!aiConfigured ? ( {!aiConfigured ? (

View File

@ -47,7 +47,7 @@ export function ProjectsNavSection({ collapsed }: { collapsed: boolean }) {
"flex flex-1 items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors", "flex flex-1 items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
"hover:bg-accent hover:text-accent-foreground", "hover:bg-accent hover:text-accent-foreground",
collapsed && "justify-center px-0", collapsed && "justify-center px-0",
isListActive && "bg-accent text-accent-foreground" isListActive && "bg-accent text-accent-foreground shadow-[inset_2px_0_0_var(--primary)]"
)} )}
> >
<FolderKanban className="size-5 shrink-0" /> <FolderKanban className="size-5 shrink-0" />

View File

@ -17,7 +17,7 @@ export function SideNavItem({ item, collapsed }: { item: NavItem; collapsed: boo
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
"hover:bg-accent hover:text-accent-foreground", "hover:bg-accent hover:text-accent-foreground",
collapsed && "justify-center px-0", collapsed && "justify-center px-0",
isActive && "bg-accent text-accent-foreground" isActive && "bg-accent text-accent-foreground shadow-[inset_2px_0_0_var(--primary)]"
)} )}
> >
<item.icon className="size-5 shrink-0" /> <item.icon className="size-5 shrink-0" />

View File

@ -4,7 +4,6 @@ import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useSideNav } from "@/components/nav/side-nav-provider"; import { useSideNav } from "@/components/nav/side-nav-provider";
import { SideNavItem } from "@/components/nav/side-nav-item"; import { SideNavItem } from "@/components/nav/side-nav-item";
@ -21,18 +20,22 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
return ( return (
<aside <aside
className={cn( className={cn(
"flex h-screen flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-200", "flex h-screen flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-300 ease-out",
collapsed ? "w-16" : "w-56" collapsed ? "w-16" : "w-60"
)} )}
> >
<div className={cn("flex items-center gap-2 p-3", collapsed && "justify-center")}> <div className={cn("flex items-center gap-2.5 px-4 py-4", collapsed && "justify-center px-0")}>
<NotebookPen className="size-6 shrink-0 text-primary" /> <span className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
{!collapsed && <span className="truncate text-lg font-semibold">Organize</span>} <NotebookPen className="size-4.5" />
</span>
{!collapsed && (
<span className="truncate font-heading text-[17px] font-bold tracking-tight">
Organize
</span>
)}
</div> </div>
<Separator /> <nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2.5 pt-1 pb-2">
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
{NAV_ITEMS.map((item) => ( {NAV_ITEMS.map((item) => (
<SideNavItem key={item.href} item={item} collapsed={collapsed} /> <SideNavItem key={item.href} item={item} collapsed={collapsed} />
))} ))}
@ -42,9 +45,7 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
))} ))}
</nav> </nav>
<Separator /> <div className="space-y-0.5 px-2.5 pb-2">
<div className="flex flex-col gap-1 p-2">
<ThemeToggle collapsed={collapsed} /> <ThemeToggle collapsed={collapsed} />
{collapsed ? ( {collapsed ? (
@ -62,21 +63,24 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
</form> </form>
) : ( ) : (
<form action={logout}> <form action={logout}>
<Button variant="ghost" type="submit" className="w-full justify-start gap-2"> <Button variant="ghost" type="submit" className="w-full justify-start gap-2.5">
<LogOut className="size-4" /> <LogOut className="size-4" />
<span className="truncate">Log out</span> <span className="truncate">Log out</span>
</Button> </Button>
</form> </form>
)} )}
</div>
<div className={cn("flex items-center gap-2 border-t px-4 py-3", collapsed && "justify-center px-0")}>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase">
{(userEmail[0] ?? "?").slice(0, 1)}
</span>
{!collapsed && ( {!collapsed && (
<p className="truncate px-3 text-xs text-muted-foreground">{userEmail}</p> <span className="truncate text-[13px] font-medium text-muted-foreground">{userEmail}</span>
)} )}
</div> </div>
<Separator /> <div className={cn("flex border-t px-2 py-1.5", collapsed ? "justify-center" : "justify-end")}>
<div className={cn("flex p-2", collapsed ? "justify-center" : "justify-end")}>
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle sidebar"> <Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle sidebar">
{collapsed ? <ChevronRight className="size-4" /> : <ChevronLeft className="size-4" />} {collapsed ? <ChevronRight className="size-4" /> : <ChevronLeft className="size-4" />}
</Button> </Button>

View File

@ -30,7 +30,7 @@ export function ProjectCard({ project }: { project: ProjectDTO }) {
element paints after (on top of) non-positioned in-flow content element paints after (on top of) non-positioned in-flow content
regardless of DOM order, so without it the Link would still win regardless of DOM order, so without it the Link would still win
every hit-test even under a pointer-events-none ancestor. */} every hit-test even under a pointer-events-none ancestor. */}
<div className="group relative flex h-32 flex-col justify-between rounded-xl border p-4 shadow-sm transition-shadow hover:-translate-y-0.5 hover:shadow-md"> <div className="group relative flex h-32 flex-col justify-between rounded-2xl border p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-md">
<Link <Link
href={`/projects/${project.id}`} href={`/projects/${project.id}`}
className="absolute inset-0 rounded-xl" className="absolute inset-0 rounded-xl"
@ -64,7 +64,7 @@ export function ProjectCard({ project }: { project: ProjectDTO }) {
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
<h3 className="pointer-events-none min-w-0 truncate text-sm font-semibold"> <h3 className="pointer-events-none min-w-0 truncate font-heading text-[15px] font-semibold tracking-tight">
{project.title} {project.title}
</h3> </h3>
</div> </div>

View File

@ -11,23 +11,31 @@ export function ProjectsListView() {
return ( return (
<div className="flex h-full flex-col gap-4 p-4"> <div className="flex h-full flex-col gap-4 p-4">
<div> <div className="flex flex-wrap items-end justify-between gap-3 px-1">
<h1 className="text-2xl font-bold">Projects</h1> <div>
<p className="text-sm text-muted-foreground"> <h1 className="font-heading text-xl font-bold tracking-tight">Projects</h1>
Each project is its own isolated board -- separate categories, groups, and to-dos from <p className="mt-0.5 text-[13px] text-muted-foreground">
Home and from every other project. Each project is its own isolated board, separate from Home.
</p> </p>
</div>
{projects.length > 0 && (
<p className="rounded-full bg-foreground/5 px-3 py-1 text-xs font-medium text-muted-foreground tabular-nums">
{projects.length} project{projects.length === 1 ? "" : "s"}
</p>
)}
</div> </div>
{projects.length === 0 && ( {projects.length === 0 && (
<div className="flex flex-col items-center gap-3 p-8 text-center"> <div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border/80 p-10 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10 text-primary"> <div className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<FolderKanban className="size-8" /> <FolderKanban className="size-7" />
</div>
<div className="space-y-1">
<h2 className="font-heading text-lg font-semibold">No projects yet</h2>
<p className="max-w-xs text-sm text-muted-foreground">
Create one to get its own board, separate from Home.
</p>
</div> </div>
<h2 className="text-lg font-semibold">No projects yet</h2>
<p className="max-w-sm text-sm text-muted-foreground">
Create one to get its own board, separate from Home.
</p>
</div> </div>
)} )}

View File

@ -118,7 +118,7 @@ export function ScheduledPanel() {
<> <>
<aside <aside
className={cn( className={cn(
"flex h-screen flex-col border-l bg-sidebar text-sidebar-foreground transition-[width] duration-200", "hidden h-screen flex-col border-l bg-sidebar text-sidebar-foreground transition-[width] duration-200 sm:flex",
collapsed ? "w-16" : "w-80" collapsed ? "w-16" : "w-80"
)} )}
> >
@ -276,10 +276,12 @@ function ExpandedPanel({
return ( return (
<> <>
<div className="flex items-center justify-between gap-2 p-3"> <div className="flex items-center justify-between gap-2 px-3 py-3">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2.5">
<CalendarClock className="size-5 shrink-0 text-primary" /> <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<span className="truncate text-lg font-semibold">Scheduled</span> <CalendarClock className="size-4" />
</span>
<span className="truncate font-heading text-[15px] font-semibold tracking-tight">Scheduled</span>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"

View File

@ -1,11 +1,154 @@
"use client"; "use client";
import { ThemeProvider as NextThemesProvider } from "next-themes"; import {
import type { ComponentProps } from "react"; createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import type { ReactNode } from "react";
/**
* App theme provider (replaces next-themes).
*
* next-themes injects its no-FOUC script as a <script> inside the React
* component tree, which React 19 (dev) rejects: "Encountered a script tag
* while rendering React component". Everything it does is small, so this
* provider implements the same API ({ theme, setTheme, resolvedTheme })
* directly, and the equivalent inline script lives in app/layout.tsx where
* it renders as plain server HTML and actually executes.
*
* Semantics (kept compatible for all existing consumers):
* - `theme` the stored preference: default | sunset | dark | ocean | system
* - `resolvedTheme` the theme actually applied (system resolved via
* prefers-color-scheme), always one of the four
* - html classes .theme-default | .theme-sunset | .dark | .ocean (see globals.css)
*/
export const THEMES = ["default", "sunset", "dark", "ocean"] as const;
export type ThemeName = (typeof THEMES)[number];
export type RawTheme = ThemeName | "system";
const STORAGE_KEY = "theme";
const THEME_CLASSES: Record<ThemeName, string> = {
default: "theme-default",
sunset: "theme-sunset",
dark: "dark",
ocean: "ocean",
};
const ALL_CLASSES = Object.values(THEME_CLASSES);
const DARK_SURFACES: ThemeName[] = ["dark", "ocean"];
interface ThemeContextValue {
theme: RawTheme;
resolvedTheme: ThemeName;
setTheme: (theme: RawTheme) => void;
themes: readonly ThemeName[];
}
const ThemeContext = createContext<ThemeContextValue>({
theme: "system",
resolvedTheme: "default",
setTheme: () => {},
themes: THEMES,
});
function systemPrefersDark(): boolean {
return (
typeof window !== "undefined" &&
!!window.matchMedia?.("(prefers-color-scheme: dark)").matches
);
}
function resolve(theme: RawTheme): ThemeName {
return theme === "system" ? (systemPrefersDark() ? "dark" : "default") : theme;
}
/** Exported so consumers (e.g. isDarkTheme) derive the applied theme from
* the same source of truth the provider uses -- and stay reactive when it
* changes. */
export function resolveTheme(theme: RawTheme): ThemeName {
return resolve(theme);
}
function applyToDom(theme: RawTheme) {
if (typeof document === "undefined") return;
const name = resolve(theme);
const root = document.documentElement;
// Also drop "light": a class next-themes applied to <html> in earlier
// versions, harmless but stale.
root.classList.remove(...ALL_CLASSES, "light");
root.classList.add(THEME_CLASSES[name]);
root.style.colorScheme = DARK_SURFACES.includes(name) ? "dark" : "light";
}
export function ThemeProvider({ export function ThemeProvider({
children, children,
...props defaultTheme = "system",
}: ComponentProps<typeof NextThemesProvider>) { }: {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>; children: ReactNode;
defaultTheme?: RawTheme;
}) {
// Both the server and the first client render agree on `defaultTheme`
// (hydration-safe); the stored preference is picked up right after mount.
// The inline script in app/layout.tsx already applied the right classes
// before first paint, so there is no visible jump either way.
const [theme, setThemeState] = useState<RawTheme>(defaultTheme);
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) setThemeState(stored as RawTheme);
} catch {
/* storage unavailable -- stay on defaultTheme */
}
}, []);
// Keep the DOM in sync whenever the preference changes -- synchronously
// (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.
useLayoutEffect(() => {
applyToDom(theme);
}, [theme]);
// Follow the OS while in "system" mode.
useEffect(() => {
if (theme !== "system" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyToDom("system");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [theme]);
// Keep multiple tabs in sync.
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) setThemeState((e.newValue || defaultTheme) as RawTheme);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [defaultTheme]);
const setTheme = useCallback((t: RawTheme) => {
setThemeState(t);
try {
localStorage.setItem(STORAGE_KEY, t);
} catch {
/* ignore -- preference just won't persist */
}
}, []);
const value = useMemo<ThemeContextValue>(
() => ({ theme, resolvedTheme: resolve(theme), setTheme, themes: THEMES }),
[theme, setTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
return useContext(ThemeContext);
} }

View File

@ -1,26 +1,43 @@
"use client"; "use client";
import { useTheme } from "next-themes"; import { useTheme } from "@/components/theme/theme-provider";
import { Moon, Sun, Monitor } from "lucide-react"; import { Moon, Sun, Monitor, Sunset, Waves, Check } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
// The four looks, shown as two "modes" with a shared System row. Each entry
// is one next-themes value (see app/layout.tsx for the class mapping); the
// little dot is the theme's own accent so the picker previews the vibe.
const OPTIONS = [ const OPTIONS = [
{ value: "light", label: "Light", icon: Sun }, { value: "default", label: "Default", icon: Sun, swatch: "#4f56e0" },
{ value: "dark", label: "Dark", icon: Moon }, { value: "sunset", label: "Sunset", icon: Sunset, swatch: "#c2410c" },
{ value: "system", label: "System", icon: Monitor }, { value: "dark", label: "Dark", icon: Moon, swatch: "#7c83f2" },
{ value: "ocean", label: "Ocean", icon: Waves, swatch: "#5eead4" },
] as const; ] as const;
const CURRENT_ICONS = {
default: Sun,
sunset: Sunset,
dark: Moon,
ocean: Waves,
system: Monitor,
} as const;
export function ThemeToggle({ collapsed }: { collapsed?: boolean }) { export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const current = OPTIONS.find((o) => o.value === theme) ?? OPTIONS[2]; // The picker always shows the four real themes; "system" is handled by a
const Icon = current.icon; // dedicated row since it resolves to one of them per device preference.
const known = OPTIONS.find((o) => o.value === theme);
const isSystem = theme === "system" || theme === undefined;
const Icon = (isSystem ? CURRENT_ICONS.system : known?.icon) ?? CURRENT_ICONS.system;
const label = isSystem ? "System" : known?.label ?? "Default";
return ( return (
<DropdownMenu> <DropdownMenu>
@ -36,13 +53,42 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
</Button> </Button>
} }
/> />
<DropdownMenuContent align="start" side="right"> <DropdownMenuContent align="start" side="right" className="w-52">
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
{OPTIONS.map((option) => ( {OPTIONS.map((option) => (
<DropdownMenuItem key={option.value} onClick={() => setTheme(option.value)}> <button
<option.icon className="size-4" /> key={option.value}
{option.label} type="button"
</DropdownMenuItem> onClick={() => setTheme(option.value)}
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"
>
<option.icon className="size-4 text-muted-foreground group-focus:text-current" />
<span>{option.label}</span>
<span
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
style={{ backgroundColor: option.swatch }}
aria-hidden
/>
{theme === option.value && <Check className="absolute right-2 -mr-4 size-4" />}
</button>
))} ))}
<DropdownMenuSeparator />
<button
type="button"
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"
>
<Monitor className="size-4 text-muted-foreground" />
<span>Match system</span>
<span
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
style={{
background: "linear-gradient(90deg, #4f56e0 50%, #7c83f2 50%)",
}}
aria-hidden
/>
{isSystem && <Check className="absolute right-2 -mr-4 size-4" />}
</button>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );

View File

@ -60,16 +60,21 @@ function DropdownMenuLabel({
}: MenuPrimitive.GroupLabel.Props & { }: MenuPrimitive.GroupLabel.Props & {
inset?: boolean inset?: boolean
}) { }) {
// GroupLabel requires a Menu.Group context (Base UI throws
// "MenuGroupContext is missing" without it), so wrap itself in one --
// callers don't need to remember to add a Group around the label.
return ( return (
<MenuPrimitive.GroupLabel <MenuPrimitive.Group>
data-slot="dropdown-menu-label" <MenuPrimitive.GroupLabel
data-inset={inset} data-slot="dropdown-menu-label"
className={cn( data-inset={inset}
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7", className={cn(
className "px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
)} className
{...props} )}
/> {...props}
/>
</MenuPrimitive.Group>
) )
} }

View File

@ -1,15 +1,19 @@
"use client" "use client"
import { useTheme } from "next-themes" import { useTheme } from "@/components/theme/theme-provider"
import { Toaster as Sonner, type ToasterProps } from "sonner" import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { resolvedTheme } = useTheme()
// Sonner only knows light/dark -- map the app's looks (default/sunset are
// light; dark/ocean are dark) onto what it understands.
const theme: ToasterProps["theme"] =
resolvedTheme === "dark" || resolvedTheme === "ocean" ? "dark" : "light"
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme}
className="toaster group" className="toaster group"
icons={{ icons={{
success: ( success: (

View File

@ -4,8 +4,19 @@
// palette *key* on the Group record (not a raw hex) so this list can be // palette *key* on the Group record (not a raw hex) so this list can be
// retinted centrally without a data migration. // retinted centrally without a data migration.
// //
// Each color has a slightly deeper/less-saturated `dark` variant so a thick // Each color ships four variants:
// border doesn't glare against a dark background. // - `light` -- the stroke for light surfaces (default / Sunset themes)
// - `dark` -- a slightly brighter stroke for dark surfaces (Dark / Ocean),
// so the border reads against a deep background without glaring
// - `soft` -- a calm, low-chroma tint of the same hue used as the card's
// fill on light themes. Derived per-color (not a generic gray)
// so every card carries a whisper of its own identity behind
// the stroke.
// - `softDark` -- the card fill on dark themes. It's the same hue as `soft`
// but at a lightness that reads as a *subtle tint over a dark
// surface* (blended with --card via color-mix), instead of the
// near-white pastel that `soft` is -- which would otherwise
// make every card a washed-out pale rectangle at night.
export type GroupColorKey = export type GroupColorKey =
| "coral" | "coral"
@ -22,17 +33,19 @@ export interface GroupColor {
label: string; label: string;
light: string; light: string;
dark: string; dark: string;
soft: string;
softDark: string;
} }
export const GROUP_COLORS: GroupColor[] = [ export const GROUP_COLORS: GroupColor[] = [
{ key: "coral", label: "Coral", light: "#f4574a", dark: "#ff7a6e" }, { key: "coral", label: "Coral", light: "#e8574a", dark: "#ff8177", soft: "#fae4e1", softDark: "#3a2320" },
{ key: "amber", label: "Amber", light: "#e8940f", dark: "#f5ad3d" }, { key: "amber", label: "Amber", light: "#dd8f0e", dark: "#f2ab3e", soft: "#f8edd6", softDark: "#3a301c" },
{ key: "lime", label: "Lime", light: "#4c9a2a", dark: "#78c94e" }, { key: "lime", label: "Lime", light: "#4c9a2a", dark: "#7cc954", soft: "#e5f0dc", softDark: "#25331d" },
{ key: "teal", label: "Teal", light: "#0f9488", dark: "#2dd4bf" }, { key: "teal", label: "Teal", light: "#0f9488", dark: "#2dd4bf", soft: "#daf1ed", softDark: "#16332f" },
{ key: "sky", label: "Sky", light: "#2f7fe0", dark: "#5b9dee" }, { key: "sky", label: "Sky", light: "#2f7fe0", dark: "#5b9dee", soft: "#e0eafa", softDark: "#1a2b40" },
{ key: "indigo", label: "Indigo", light: "#5b5bd6", dark: "#8c8cf0" }, { key: "indigo", label: "Indigo", light: "#5b5bd6", dark: "#8c8cf0", soft: "#e4e4f7", softDark: "#23233c" },
{ key: "violet", label: "Violet", light: "#9440d1", dark: "#c084fc" }, { key: "violet", label: "Violet", light: "#9440d1", dark: "#c084fc", soft: "#eddff9", softDark: "#2e2136" },
{ key: "pink", label: "Pink", light: "#d63f8f", dark: "#f472b6" }, { key: "pink", label: "Pink", light: "#d63f8f", dark: "#f472b6", soft: "#fadfee", softDark: "#37202e" },
]; ];
const GROUP_COLOR_MAP: Record<string, GroupColor> = Object.fromEntries( const GROUP_COLOR_MAP: Record<string, GroupColor> = Object.fromEntries(
@ -116,7 +129,7 @@ function hslToHex(h: number, s: number, l: number): string {
const complementaryCache = new Map<string, string>(); const complementaryCache = new Map<string, string>();
/** The opposing accent color for a given base hex, tuned per light/dark theme. */ /** The opposing accent color for a given base hex, tuned per light/dark surface. */
export function getComplementaryColor(hex: string, variant: "light" | "dark"): string { export function getComplementaryColor(hex: string, variant: "light" | "dark"): string {
const cacheKey = `${hex}:${variant}`; const cacheKey = `${hex}:${variant}`;
const cached = complementaryCache.get(cacheKey); const cached = complementaryCache.get(cacheKey);
@ -137,7 +150,7 @@ const brighterCache = new Map<string, string>();
/** /**
* A brighter tint of a Group's own color, same hue, for un-checked to-do * A brighter tint of a Group's own color, same hue, for un-checked to-do
* text. Deliberately distinct from the plain foreground color (which would * text. Deliberately distinct from the plain foreground color (which would
* otherwise render pure white in dark mode) so the group title -- which * otherwise render near-white on dark themes) so the group title -- which
* keeps the default foreground color -- stays the most prominent text on * keeps the default foreground color -- stays the most prominent text on
* the card instead of competing with its own to-dos. * the card instead of competing with its own to-dos.
* *