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

View File

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

View File

@ -30,7 +30,11 @@ export default async function AppLayout({ children }: { children: React.ReactNod
<ProjectsProvider initialProjects={projects}>
<BoardViewProvider>
<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} />
<main className="flex-1 overflow-auto">{children}</main>
<ScheduledPanel />

View File

@ -1,7 +1,17 @@
import { NotebookPen } from "lucide-react";
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-muted/40 p-4">
<div className="w-full max-w-sm">{children}</div>
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<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>
);
}

View File

@ -4,14 +4,16 @@
@import "@uiw/react-md-editor/markdown-editor.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 {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--font-mono: var(--font-mono);
--font-heading: var(--font-heading);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@ -41,6 +43,10 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--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-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
@ -50,77 +56,173 @@
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
/* ---------------------------------------------------------------------------
Themes
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-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.235 0.02 265);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
/* Single calm accent (indigo/violet) for interactive chrome -- buttons,
active nav, focus rings, links -- kept deliberately separate from the
curated vibrant Group palette in lib/colors.ts so the group colors stay
the only "loud" color on screen. */
--primary: oklch(0.53 0.24 274);
--popover-foreground: oklch(0.235 0.02 265);
/* Single calm accent (indigo) for interactive chrome -- buttons, active
nav, focus rings -- kept deliberately separate from the curated vibrant
Group palette in lib/colors.ts so the group colors stay the only
"loud" color on screen. */
--primary: oklch(0.53 0.2 272);
--primary-foreground: oklch(0.98 0.01 274);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.94 0.03 274);
--accent-foreground: oklch(0.33 0.16 274);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.53 0.24 274);
--secondary: oklch(0.955 0.004 260);
--secondary-foreground: oklch(0.31 0.02 265);
--muted: oklch(0.95 0.004 260);
--muted-foreground: oklch(0.52 0.02 265);
--accent: oklch(0.95 0.022 272);
--accent-foreground: oklch(0.35 0.12 272);
--destructive: oklch(0.58 0.21 27);
--border: oklch(0.905 0.004 260);
--input: oklch(0.885 0.004 260);
--ring: oklch(0.58 0.15 272);
--lane: oklch(0.95 0.005 260);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.53 0.24 274);
--radius: 0.75rem;
--sidebar: oklch(0.975 0.003 260);
--sidebar-foreground: oklch(0.235 0.02 265);
--sidebar-primary: oklch(0.53 0.2 272);
--sidebar-primary-foreground: oklch(0.98 0.01 274);
--sidebar-accent: oklch(0.94 0.03 274);
--sidebar-accent-foreground: oklch(0.33 0.16 274);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.53 0.24 274);
--sidebar-accent: oklch(0.95 0.022 272);
--sidebar-accent-foreground: oklch(0.35 0.12 272);
--sidebar-border: oklch(0.905 0.004 260);
--sidebar-ring: oklch(0.58 0.15 272);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.73 0.17 276);
--primary-foreground: oklch(0.17 0.05 274);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.3 0.08 274);
--accent-foreground: oklch(0.9 0.04 274);
--destructive: oklch(0.704 0.191 22.216);
color-scheme: dark;
--background: oklch(0.175 0.012 265);
--foreground: oklch(0.93 0.01 265);
--card: oklch(0.22 0.013 265);
--card-foreground: oklch(0.93 0.01 265);
--popover: oklch(0.23 0.014 265);
--popover-foreground: oklch(0.93 0.01 265);
--primary: oklch(0.7 0.14 272);
--primary-foreground: oklch(0.16 0.04 272);
--secondary: oklch(0.27 0.012 265);
--secondary-foreground: oklch(0.93 0.01 265);
--muted: oklch(0.26 0.013 265);
--muted-foreground: oklch(0.68 0.02 265);
--accent: oklch(0.31 0.04 272);
--accent-foreground: oklch(0.9 0.03 272);
--destructive: oklch(0.68 0.19 24);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.73 0.17 276);
--input: oklch(1 0 0 / 14%);
--ring: oklch(0.68 0.14 272);
--lane: oklch(1 0 0 / 5%);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.73 0.17 276);
--sidebar-primary-foreground: oklch(0.17 0.05 274);
--sidebar-accent: oklch(0.3 0.08 274);
--sidebar-accent-foreground: oklch(0.9 0.04 274);
--radius: 0.75rem;
--sidebar: oklch(0.205 0.013 265);
--sidebar-foreground: oklch(0.93 0.01 265);
--sidebar-primary: oklch(0.7 0.14 272);
--sidebar-primary-foreground: oklch(0.16 0.04 272);
--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-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 {
@ -129,8 +231,117 @@
}
body {
@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 {
@apply font-sans;
/* Headings use the display face (see --font-heading / Figtree). */
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 { Geist, Geist_Mono } from "next/font/google";
import { Figtree, Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme/theme-provider";
@ -7,13 +7,23 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import { RegisterServiceWorker } from "@/components/pwa/register-sw";
const geistSans = Geist({
variable: "--font-geist-sans",
/** UI type: Inter -- a neutral, highly legible humanist sans that reads
* cleanly at small sizes (to-do lists, tables, nav). */
const inter = Inter({
variable: "--font-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
/** Display type: Figtree -- a slightly friendlier, rounder geometric sans
* 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"],
});
@ -34,8 +44,8 @@ export const metadata: Metadata = {
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#171717" },
{ media: "(prefers-color-scheme: light)", color: "#fafafa" },
{ media: "(prefers-color-scheme: dark)", color: "#22252b" },
],
};
@ -43,11 +53,21 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${inter.variable} ${figtree.variable} ${jetbrainsMono.variable} h-full antialiased`}
suppressHydrationWarning
>
<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}>
{children}
<Toaster />

View File

@ -49,12 +49,21 @@ export function UserRoleMenu({ userId, role }: { userId: string; role: Role }) {
<DropdownMenu>
<DropdownMenuTrigger
render={
// Base UI requires a native <button> trigger -- so the Badge
// (a <span>) sits inside a bare reset button instead of being
// 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]})`}
>
<Badge
variant={ROLE_BADGE_VARIANT[current]}
className="cursor-pointer select-none"
>
{ROLE_LABEL[current]}
</Badge>
</button>
}
/>
<DropdownMenuContent align="start">

View File

@ -38,11 +38,13 @@ export function AddCategoryLane() {
<DialogTrigger
render={
<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"
>
<Plus className="size-6" />
<span className="text-sm font-medium">Add Category</span>
<span className="flex size-9 items-center justify-center rounded-full bg-foreground/5">
<Plus className="size-5" />
</span>
<span className="text-sm font-medium">Add lane</span>
</button>
}
/>

View File

@ -49,7 +49,10 @@ export function AddGroupPopover({ categoryId }: { categoryId: string }) {
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
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" />
Group
</Button>

View File

@ -5,7 +5,7 @@ import { useSortable } from "@dnd-kit/sortable";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useDndContext, useDroppable } from "@dnd-kit/core";
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 {
@ -86,6 +86,12 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
});
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 (
<>
@ -93,21 +99,34 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
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"
)}
>
<div className="flex items-center gap-1 p-3">
<div className="flex items-center gap-1.5 px-3 pt-3 pb-2">
<button
ref={setActivatorNodeRef}
{...attributes}
{...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}`}
>
<GripVertical className="size-4" />
<GripVertical className="size-3.5" />
</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>
<DropdownMenuTrigger
render={
@ -139,9 +158,8 @@ 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-lg px-3 pt-2 pb-2 transition-colors",
isDropTargetLane &&
"bg-primary/10 outline-2 outline-dashed outline-primary/50 -outline-offset-2"
"flex-1 space-y-2 overflow-y-auto rounded-t-lg px-2.5 pt-1.5 pb-2 transition-colors",
isDropTargetLane && "bg-primary/10"
)}
>
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>

View File

@ -1,10 +1,10 @@
"use client";
import { Check } from "lucide-react";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
import { GROUP_COLORS, type GroupColorKey } from "@/lib/colors";
import { isDarkTheme } from "@/components/theme/use-dark-theme";
export function ColorSwatchPicker({
value,
@ -13,8 +13,7 @@ export function ColorSwatchPicker({
value: GroupColorKey;
onChange: (key: GroupColorKey) => void;
}) {
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const isDark = isDarkTheme();
return (
<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() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-12 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10 text-primary">
<LayoutDashboard className="size-8" />
<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-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<LayoutDashboard className="size-7" />
</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.
<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>
);
}

View File

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

View File

@ -1,9 +1,9 @@
"use client";
import { useTheme } from "next-themes";
import { NotebookPen } from "lucide-react";
import { getGroupColor } from "@/lib/colors";
import { isDarkTheme } from "@/components/theme/use-dark-theme";
import { cn } from "@/lib/utils";
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.
*/
export function GroupCardOverlay({ group }: { group: GroupDTO }) {
const { resolvedTheme } = useTheme();
const isDark = isDarkTheme();
const color = getGroupColor(group.color);
const isDark = resolvedTheme === "dark";
const borderColor = isDark ? color.dark : color.light;
const backgroundColor = isDark
? `color-mix(in srgb, ${color.dark} 18%, var(--card))`
: `color-mix(in srgb, ${color.light} 12%, var(--card))`;
const backgroundColor = `color-mix(in srgb, ${isDark ? color.softDark : color.soft} 85%, var(--card))`;
return (
<div
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">
<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 { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useTheme } from "next-themes";
import {
Archive,
ChevronRight,
@ -19,6 +18,7 @@ import {
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TodoCheckbox } from "@/components/board/todo-checkbox";
import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip";
import {
@ -29,6 +29,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors";
import { isDarkTheme } from "@/components/theme/use-dark-theme";
import { useBoard } from "@/components/board/board-context";
import { useBoardView } from "@/components/board/board-view-provider";
import { useHoldOnComplete } from "@/components/hold-on-complete";
@ -90,7 +91,9 @@ export function GroupCard({ group }: { group: GroupDTO }) {
const { view } = useBoardView();
const { holds, beginHold, cancelHold } = useHoldOnComplete();
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 [editOpen, setEditOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
@ -119,15 +122,14 @@ export function GroupCard({ group }: { group: GroupDTO }) {
const isDropTarget = isOver && !isDragging;
const color = getGroupColor(group.color);
const isDark = resolvedTheme === "dark";
const borderColor = isDark ? color.dark : color.light;
// A subdued tint of the same stroke color, blended into the theme's own
// card surface -- not a fixed pastel, so it automatically stays correct
// if the neutral card token ever changes, and needs no separate
// light/dark background palette to hand-tune.
const backgroundColor = isDark
? `color-mix(in srgb, ${color.dark} 18%, var(--card))`
: `color-mix(in srgb, ${color.light} 12%, var(--card))`;
// The card fill is a calm, hand-picked tint of the same stroke color
// (`soft` / `softDark` in lib/colors.ts), blended a little into the theme's
// own card surface so it sits naturally on any theme -- the 85% weight
// keeps the tint in charge while still letting a tinted theme surface show
// through. Dark themes use `softDark`: the same hue at a dark-surface
// lightness, instead of the near-white pastel of `soft`.
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 --
// so it reads as a distinct accent rather than blending into the border.
const pieAccentColor = getComplementaryColor(
@ -191,7 +193,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
backgroundColor,
}}
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",
isDragging && "opacity-50 shadow-lg",
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" />
</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 className="flex shrink-0 items-center">
<NotesDialog group={group} />
@ -263,7 +267,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
{!isCollapsed && (
<>
<ul className="mt-1 space-y-1">
<ul className="mt-2 space-y-0.5">
{visibleTodos.map((todo, index) => {
const todoButton = (
<button
@ -271,8 +275,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
onClick={() => setEditingTodo(todo)}
style={todo.completed ? undefined : { color: todoTextColor }}
className={cn(
"flex min-w-0 flex-1 items-center gap-1 text-left text-sm hover:underline",
todo.completed && "text-muted-foreground line-through"
"-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 hover:bg-transparent"
)}
>
<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} />
</div>
@ -377,16 +381,30 @@ export function GroupCard({ group }: { group: GroupDTO }) {
</Button>
)}
{(canArchive || group.todos.length > 0) && (
<div className="mt-1 flex items-center gap-1 border-t border-foreground/[0.07] pt-1.5">
{canArchive && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground"
size="icon-sm"
className="shrink-0 text-muted-foreground"
onClick={() => archiveGroup(group.id, group.categoryId)}
aria-label="Archive group"
>
<Archive className="size-3.5" />
Archive
</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

View File

@ -13,6 +13,7 @@ import {
type DragStartEvent,
} from "@dnd-kit/core";
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
import { LayoutDashboard, Plus } from "lucide-react";
import { BoardProvider, useBoard } from "@/components/board/board-context";
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 { EmptyState } from "@/components/board/empty-state";
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";
function Board({ title }: { title: string }) {
const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
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(
// A short activation distance lets a plain click (checkbox, notes icon,
@ -110,16 +121,58 @@ function Board({ title }: { title: string }) {
return (
<DndContext
id="board-dnd"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex h-full flex-col gap-4 p-4">
<div className="flex items-center justify-between gap-2 px-1">
<h1 className="truncate text-2xl font-bold">{title}</h1>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-1">
<div className="flex min-w-0 items-center gap-3">
<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>
{categories.length === 0 ? (
<div className="flex flex-1 items-center gap-4 overflow-x-auto">
@ -139,6 +192,15 @@ function Board({ title }: { title: string }) {
</div>
<DragOverlay>{draggedGroup ? <GroupCardOverlay group={draggedGroup} /> : null}</DragOverlay>
{quickTargetCategory && quickTargetGroup && (
<TodoCreateDialog
groupId={quickTargetGroup.id}
categoryId={quickTargetCategory.id}
open={quickAddOpen}
onOpenChange={setQuickAddOpen}
/>
)}
</DndContext>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,10 @@
"use client";
import { useState } from "react";
import { useTheme } from "next-themes";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -21,8 +21,7 @@ export function TodoCreatePopover({
categoryId: string;
}) {
const { addTodo } = useBoard();
const { resolvedTheme } = useTheme();
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
const colorMode = colorModeFromTheme();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
@ -50,7 +49,7 @@ export function TodoCreatePopover({
<Button
variant="ghost"
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" />
Add to-do

View File

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

View File

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

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",
"hover:bg-accent hover:text-accent-foreground",
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" />

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",
"hover:bg-accent hover:text-accent-foreground",
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" />

View File

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

View File

@ -30,7 +30,7 @@ export function ProjectCard({ project }: { project: ProjectDTO }) {
element paints after (on top of) non-positioned in-flow content
regardless of DOM order, so without it the Link would still win
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
href={`/projects/${project.id}`}
className="absolute inset-0 rounded-xl"
@ -64,7 +64,7 @@ export function ProjectCard({ project }: { project: ProjectDTO }) {
</DropdownMenu>
</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}
</h3>
</div>

View File

@ -11,24 +11,32 @@ export function ProjectsListView() {
return (
<div className="flex h-full flex-col gap-4 p-4">
<div className="flex flex-wrap items-end justify-between gap-3 px-1">
<div>
<h1 className="text-2xl font-bold">Projects</h1>
<p className="text-sm text-muted-foreground">
Each project is its own isolated board -- separate categories, groups, and to-dos from
Home and from every other project.
<h1 className="font-heading text-xl font-bold tracking-tight">Projects</h1>
<p className="mt-0.5 text-[13px] text-muted-foreground">
Each project is its own isolated board, separate from Home.
</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>
{projects.length === 0 && (
<div className="flex flex-col items-center gap-3 p-8 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10 text-primary">
<FolderKanban className="size-8" />
<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-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<FolderKanban className="size-7" />
</div>
<h2 className="text-lg font-semibold">No projects yet</h2>
<p className="max-w-sm text-sm text-muted-foreground">
<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>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4">

View File

@ -118,7 +118,7 @@ export function ScheduledPanel() {
<>
<aside
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"
)}
>
@ -276,10 +276,12 @@ function ExpandedPanel({
return (
<>
<div className="flex items-center justify-between gap-2 p-3">
<div className="flex min-w-0 items-center gap-2">
<CalendarClock className="size-5 shrink-0 text-primary" />
<span className="truncate text-lg font-semibold">Scheduled</span>
<div className="flex items-center justify-between gap-2 px-3 py-3">
<div className="flex min-w-0 items-center gap-2.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<CalendarClock className="size-4" />
</span>
<span className="truncate font-heading text-[15px] font-semibold tracking-tight">Scheduled</span>
</div>
<Button
variant="ghost"

View File

@ -1,11 +1,154 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ComponentProps } from "react";
import {
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({
children,
...props
}: ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
defaultTheme = "system",
}: {
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";
import { useTheme } from "next-themes";
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "@/components/theme/theme-provider";
import { Moon, Sun, Monitor, Sunset, Waves, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} 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 = [
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
{ value: "system", label: "System", icon: Monitor },
{ value: "default", label: "Default", icon: Sun, swatch: "#4f56e0" },
{ value: "sunset", label: "Sunset", icon: Sunset, swatch: "#c2410c" },
{ value: "dark", label: "Dark", icon: Moon, swatch: "#7c83f2" },
{ value: "ocean", label: "Ocean", icon: Waves, swatch: "#5eead4" },
] as const;
const CURRENT_ICONS = {
default: Sun,
sunset: Sunset,
dark: Moon,
ocean: Waves,
system: Monitor,
} as const;
export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
const { theme, setTheme } = useTheme();
const current = OPTIONS.find((o) => o.value === theme) ?? OPTIONS[2];
const Icon = current.icon;
// The picker always shows the four real themes; "system" is handled by a
// 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 (
<DropdownMenu>
@ -36,13 +53,42 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
</Button>
}
/>
<DropdownMenuContent align="start" side="right">
<DropdownMenuContent align="start" side="right" className="w-52">
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
{OPTIONS.map((option) => (
<DropdownMenuItem key={option.value} onClick={() => setTheme(option.value)}>
<option.icon className="size-4" />
{option.label}
</DropdownMenuItem>
<button
key={option.value}
type="button"
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>
</DropdownMenu>
);

View File

@ -60,7 +60,11 @@ function DropdownMenuLabel({
}: MenuPrimitive.GroupLabel.Props & {
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 (
<MenuPrimitive.Group>
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
@ -70,6 +74,7 @@ function DropdownMenuLabel({
)}
{...props}
/>
</MenuPrimitive.Group>
)
}

View File

@ -1,15 +1,19 @@
"use client"
import { useTheme } from "next-themes"
import { useTheme } from "@/components/theme/theme-provider"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
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 (
<Sonner
theme={theme as ToasterProps["theme"]}
theme={theme}
className="toaster group"
icons={{
success: (

View File

@ -4,8 +4,19 @@
// palette *key* on the Group record (not a raw hex) so this list can be
// retinted centrally without a data migration.
//
// Each color has a slightly deeper/less-saturated `dark` variant so a thick
// border doesn't glare against a dark background.
// Each color ships four variants:
// - `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 =
| "coral"
@ -22,17 +33,19 @@ export interface GroupColor {
label: string;
light: string;
dark: string;
soft: string;
softDark: string;
}
export const GROUP_COLORS: GroupColor[] = [
{ key: "coral", label: "Coral", light: "#f4574a", dark: "#ff7a6e" },
{ key: "amber", label: "Amber", light: "#e8940f", dark: "#f5ad3d" },
{ key: "lime", label: "Lime", light: "#4c9a2a", dark: "#78c94e" },
{ key: "teal", label: "Teal", light: "#0f9488", dark: "#2dd4bf" },
{ key: "sky", label: "Sky", light: "#2f7fe0", dark: "#5b9dee" },
{ key: "indigo", label: "Indigo", light: "#5b5bd6", dark: "#8c8cf0" },
{ key: "violet", label: "Violet", light: "#9440d1", dark: "#c084fc" },
{ key: "pink", label: "Pink", light: "#d63f8f", dark: "#f472b6" },
{ key: "coral", label: "Coral", light: "#e8574a", dark: "#ff8177", soft: "#fae4e1", softDark: "#3a2320" },
{ key: "amber", label: "Amber", light: "#dd8f0e", dark: "#f2ab3e", soft: "#f8edd6", softDark: "#3a301c" },
{ key: "lime", label: "Lime", light: "#4c9a2a", dark: "#7cc954", soft: "#e5f0dc", softDark: "#25331d" },
{ key: "teal", label: "Teal", light: "#0f9488", dark: "#2dd4bf", soft: "#daf1ed", softDark: "#16332f" },
{ key: "sky", label: "Sky", light: "#2f7fe0", dark: "#5b9dee", soft: "#e0eafa", softDark: "#1a2b40" },
{ key: "indigo", label: "Indigo", light: "#5b5bd6", dark: "#8c8cf0", soft: "#e4e4f7", softDark: "#23233c" },
{ key: "violet", label: "Violet", light: "#9440d1", dark: "#c084fc", soft: "#eddff9", softDark: "#2e2136" },
{ key: "pink", label: "Pink", light: "#d63f8f", dark: "#f472b6", soft: "#fadfee", softDark: "#37202e" },
];
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>();
/** 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 {
const cacheKey = `${hex}:${variant}`;
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
* 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
* the card instead of competing with its own to-dos.
*