diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx index 20adc34..049853c 100644 --- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx +++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx @@ -7,6 +7,7 @@ import type { Project, ColumnDef } from "@planarchy/shared"; import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared"; import Link from "next/link"; import { clsx } from "clsx"; +import { motion } from "framer-motion"; import { trpc } from "~/lib/trpc/client.js"; import { ProjectModal } from "~/components/projects/ProjectModal.js"; import { ProjectWizard } from "~/components/projects/ProjectWizard.js"; @@ -116,9 +117,12 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project {isOpen && createPortal( -
{ALL_STATUSES.map((s) => ( @@ -139,7 +143,7 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project ))} -
, + , document.body, )} @@ -575,7 +579,7 @@ export function ProjectsClient() { id={project.id} dragRef={rowDragRef} onDrop={(draggedId) => reorder(draggedId, project.id)} - className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} + className={`table-row-hover hover-lift hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} style={{ animationDelay: `${Math.min(index * 15, 300)}ms` }} > @@ -602,11 +606,11 @@ export function ProjectsClient() { - + View → diff --git a/apps/web/src/app/(app)/resources/ResourcesClient.tsx b/apps/web/src/app/(app)/resources/ResourcesClient.tsx index d1141c4..f98473c 100644 --- a/apps/web/src/app/(app)/resources/ResourcesClient.tsx +++ b/apps/web/src/app/(app)/resources/ResourcesClient.tsx @@ -1114,7 +1114,7 @@ export function ResourcesClient() { id={resource.id} dragRef={rowDragRef} onDrop={(draggedId) => reorder(draggedId, resource.id)} - className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} + className={`table-row-hover hover-lift hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} style={{ animationDelay: `${Math.min(index * 15, 300)}ms` }} > @@ -1386,7 +1386,7 @@ export function ResourcesClient() { onClick={() => setModal({ type: "edit", resource: resource as unknown as Resource }) } - className="mr-3 text-xs font-medium text-brand-600 transition-colors hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100" + className="link-hover-underline mr-3 text-xs font-medium text-brand-600 transition-colors hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100" > Edit diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 9ce0fd7..b67eda4 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -437,11 +437,12 @@ .allocation-block { @apply absolute rounded-md text-xs font-medium px-2 py-1 cursor-pointer select-none; - @apply transition-all duration-150 ease-in-out; + transition: transform 0.1s ease-out, box-shadow 0.1s ease-out, opacity 0.15s ease-in-out; } .allocation-block:hover { - @apply ring-2 ring-white ring-offset-1; + @apply ring-2 ring-white ring-offset-1 shadow-md; + transform: scale(1.02); } .allocation-block.dragging { @@ -510,3 +511,48 @@ :is(.dark) .hover-lift:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); } + +/* ─── Smooth scroll + reduced-motion accessibility ────────────────────────── */ +html { + scroll-behavior: smooth; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ─── Table row hover accent border ──────────────────────────────────────── */ +.table-row-hover { + border-left: 2px solid transparent; + transition: border-color 0.15s ease-out, background-color 0.15s ease-out, transform 0.15s ease-out, box-shadow 0.15s ease-out; +} +.table-row-hover:hover { + border-left-color: rgb(var(--accent-400)); +} + +/* ─── Animated underline for action links ─────────────────────────────────── */ +.link-hover-underline { + position: relative; +} +.link-hover-underline::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + width: 0; + height: 1px; + background: currentColor; + transition: width 0.2s ease-out; +} +.link-hover-underline:hover::after { + width: 100%; +} diff --git a/apps/web/src/components/allocations/AllocationsClient.tsx b/apps/web/src/components/allocations/AllocationsClient.tsx index 05977d7..ac14db1 100644 --- a/apps/web/src/components/allocations/AllocationsClient.tsx +++ b/apps/web/src/components/allocations/AllocationsClient.tsx @@ -21,6 +21,7 @@ import { useColumnConfig } from "~/hooks/useColumnConfig.js"; import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js"; +import { SuccessToast } from "~/components/ui/SuccessToast.js"; /** Left-border color by allocation status for instant visual scanning */ const STATUS_LEFT_BORDER: Record = { @@ -63,6 +64,7 @@ export function AllocationsClient() { const [confirmDelete, setConfirmDelete] = useState<{ single?: AllocationWithDetails; ids?: string[] } | null>(null); const [batchStatusPicker, setBatchStatusPicker] = useState(false); const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null); + const [showStatusToast, setShowStatusToast] = useState(false); const selection = useSelection(); const utils = trpc.useUtils(); @@ -113,6 +115,7 @@ export function AllocationsClient() { await utils.allocation.list.invalidate(); await utils.allocation.listView.invalidate(); selection.clear(); + setShowStatusToast(true); }, }); @@ -438,6 +441,7 @@ export function AllocationsClient() { return (
+ setShowStatusToast(false)} />

Allocations

diff --git a/apps/web/src/components/dashboard/WidgetContainer.tsx b/apps/web/src/components/dashboard/WidgetContainer.tsx index 812cda9..91de87c 100644 --- a/apps/web/src/components/dashboard/WidgetContainer.tsx +++ b/apps/web/src/components/dashboard/WidgetContainer.tsx @@ -15,8 +15,8 @@ export function WidgetContainer({ title, onRemove, children, isDragging }: Widge initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.35, ease: "easeOut" }} - className={`flex flex-col h-full bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden ${ - isDragging ? "shadow-lg border-brand-300" : "" + className={`flex flex-col h-full bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden transition-colors duration-200 ${ + isDragging ? "shadow-lg border-brand-300" : "hover:border-brand-200 dark:hover:border-brand-800" }`} > {/* Header */} diff --git a/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx b/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx index c8bfb28..ba097f4 100644 --- a/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx @@ -44,7 +44,7 @@ function StatCard({ return (
diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 63cfe7e..fd4da18 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import type { Route } from "next"; import { usePathname } from "next/navigation"; import { clsx } from "clsx"; -import { Suspense, useMemo, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { PreferencesModal } from "./PreferencesModal.js"; import { ThemeProvider } from "./ThemeProvider.js"; @@ -15,9 +15,11 @@ import { NotificationBell } from "../notifications/NotificationBell.js"; import { ChatPanel } from "../assistant/ChatPanel.js"; import { NavProgressBar } from "~/components/ui/NavProgressBar.js"; +const SIDEBAR_COLLAPSED_KEY = "planarchy_sidebar_collapsed"; + function IconFrame({ children }: { children: ReactNode }) { return ( - + {children} ); @@ -69,6 +71,35 @@ function AdminIcon() { return ; } +function CollapseIcon({ collapsed }: { collapsed: boolean }) { + return ( + + + + ); +} + +function HamburgerIcon() { + return ( + + + + ); +} + +function CloseIcon() { + return ( + + + + ); +} + type NavItem = { href: string; label: string; icon: ReactNode; roles: string[] }; type NavSection = { label: string; collapsed?: boolean; items: NavItem[] }; @@ -150,8 +181,6 @@ const adminNavEntries: AdminEntry[] = [ /** * Collect every href registered in the sidebar so that the active-check * can determine whether a more-specific sibling matches the current path. - * Example: when pathname is `/vacations/my`, the item `/vacations` must NOT - * highlight because `/vacations/my` is a more-specific registered route. */ const ALL_NAV_HREFS: string[] = (() => { const hrefs: string[] = []; @@ -171,19 +200,57 @@ const ALL_NAV_HREFS: string[] = (() => { function isNavItemActive(pathname: string, href: string): boolean { if (pathname === href) return true; if (!pathname.startsWith(href + "/")) return false; - // pathname starts with `href/...` — but a more-specific registered route may match. - // If another nav href is a longer prefix match, this shorter one should NOT be active. const hasMoreSpecificSibling = ALL_NAV_HREFS.some( (other) => other !== href && other.length > href.length && pathname.startsWith(other), ); return !hasMoreSpecificSibling; } -function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => void }) { +/* ------------------------------------------------------------------ */ +/* Tooltip wrapper — shows label on hover when sidebar is collapsed */ +/* ------------------------------------------------------------------ */ + +function NavTooltip({ + label, + show, + children, +}: { + label: string; + show: boolean; + children: ReactNode; +}) { + if (!show) return <>{children}; + return ( +
+ {children} +
+ {label} +
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Sidebar component */ +/* ------------------------------------------------------------------ */ + +function SidebarContent({ + userRole, + onChatOpen, + sidebarCollapsed, + onToggleCollapse, + onNavClick, +}: { + userRole: string; + onChatOpen: () => void; + sidebarCollapsed: boolean; + onToggleCollapse: () => void; + onNavClick?: () => void; +}) { const pathname = usePathname(); const [prefsOpen, setPrefsOpen] = useState(false); - // Memoize active href set — avoids O(n²) on every render const activeHrefSet = useMemo(() => { const set = new Set(); for (const href of ALL_NAV_HREFS) { @@ -201,7 +268,6 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => const showAdmin = userRole === "ADMIN"; const showManagerSection = userRole === "ADMIN" || userRole === "MANAGER"; - // Sections and sub-groups auto-expand when the current route matches an item inside them const [collapsedSections, setCollapsedSections] = useState>(() => { const initial: Record = {}; for (const section of visibleSections) { @@ -223,32 +289,45 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => setCollapsedSections((prev) => ({ ...prev, [label]: !prev[label] })); }; + const handleLinkClick = () => { + onNavClick?.(); + }; + return ( <> - + + + {/* Collapse toggle */} + + + +
{prefsOpen && setPrefsOpen(false)} />} ); } +/* ------------------------------------------------------------------ */ +/* Desktop sidebar wrapper */ +/* ------------------------------------------------------------------ */ + +function DesktopSidebar({ + userRole, + onChatOpen, + sidebarCollapsed, + onToggleCollapse, +}: { + userRole: string; + onChatOpen: () => void; + sidebarCollapsed: boolean; + onToggleCollapse: () => void; +}) { + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Mobile sidebar overlay */ +/* ------------------------------------------------------------------ */ + +function MobileSidebar({ + open, + onClose, + userRole, + onChatOpen, +}: { + open: boolean; + onClose: () => void; + userRole: string; + onChatOpen: () => void; +}) { + return ( + + {open && ( + <> + {/* Backdrop */} + + {/* Slide-in sidebar */} + + {/* Close button */} + + + { + onChatOpen(); + onClose(); + }} + sidebarCollapsed={false} + onToggleCollapse={() => {}} + onNavClick={onClose} + /> + + + )} + + ); +} + +/* ------------------------------------------------------------------ */ +/* AppShell (main export) */ +/* ------------------------------------------------------------------ */ + export function AppShell({ children, userRole = "USER" }: { children: React.ReactNode; userRole?: string }) { const [chatOpen, setChatOpen] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const pathname = usePathname(); + const contentRef = useRef(null); + + // Read collapsed state from localStorage on mount (avoid SSR hydration mismatch) + useEffect(() => { + try { + const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY); + if (stored === "true") setSidebarCollapsed(true); + } catch { + // localStorage unavailable — ignore + } + }, []); + + // Persist collapsed state + const handleToggleCollapse = useCallback(() => { + setSidebarCollapsed((prev) => { + const next = !prev; + try { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next)); + } catch { + // ignore + } + return next; + }); + }, []); + + // Scroll to top on route change + useEffect(() => { + contentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }, [pathname]); + + // Close mobile sidebar on route change + useEffect(() => { + setMobileOpen(false); + }, [pathname]); return ( @@ -479,13 +789,42 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
- setChatOpen(true)} /> -
+ {/* Desktop sidebar */} + setChatOpen(true)} + sidebarCollapsed={sidebarCollapsed} + onToggleCollapse={handleToggleCollapse} + /> + + {/* Mobile sidebar overlay */} + setMobileOpen(false)} + userRole={userRole} + onChatOpen={() => setChatOpen(true)} + /> + + {/* Main content area */} +
+ {/* Mobile hamburger */} +
+ + + Planarchy + +
{children}
{chatOpen && setChatOpen(false)} />}
- {/* Floating chat FAB — always visible when panel is closed */} + {/* Floating chat FAB */} {!chatOpen && (
); } diff --git a/apps/web/src/components/notifications/NotificationBell.tsx b/apps/web/src/components/notifications/NotificationBell.tsx index c223c78..9c37753 100644 --- a/apps/web/src/components/notifications/NotificationBell.tsx +++ b/apps/web/src/components/notifications/NotificationBell.tsx @@ -192,9 +192,12 @@ export function NotificationBell() { {/* Dropdown panel — rendered via portal to escape sidebar overflow */} {open && createPortal( -
{/* Header */} @@ -383,7 +386,7 @@ export function NotificationBell() { View all →
-
, + , document.body, )}
diff --git a/apps/web/src/components/projects/ProjectWizard.tsx b/apps/web/src/components/projects/ProjectWizard.tsx index 0940e72..e81b108 100644 --- a/apps/web/src/components/projects/ProjectWizard.tsx +++ b/apps/web/src/components/projects/ProjectWizard.tsx @@ -11,6 +11,8 @@ import { SkillTagInput } from "~/components/ui/SkillTagInput.js"; import { usePermissions } from "~/hooks/usePermissions.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { formatCents } from "~/lib/format.js"; +import { ConfettiBurst } from "~/components/ui/ConfettiBurst.js"; +import { SuccessToast } from "~/components/ui/SuccessToast.js"; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -1023,6 +1025,8 @@ export function ProjectWizard({ open, onClose }: ProjectWizardProps) { const [state, setState] = useState(makeDefaultState); const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); + const [showConfetti, setShowConfetti] = useState(false); + const [showSuccessToast, setShowSuccessToast] = useState(false); const createProject = trpc.project.create.useMutation(); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1139,7 +1143,12 @@ export function ProjectWizard({ open, onClose }: ProjectWizardProps) { await utils.project.list.invalidate(); await utils.timeline.getEntries.invalidate(); await utils.timeline.getEntriesView.invalidate(); - handleClose(); + setShowConfetti(true); + setShowSuccessToast(true); + setTimeout(() => { + setShowConfetti(false); + handleClose(); + }, 1200); } catch (err) { setSubmitError(err instanceof Error ? err.message : "Failed to create project"); } finally { @@ -1158,7 +1167,14 @@ export function ProjectWizard({ open, onClose }: ProjectWizardProps) { className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8 px-4" onClick={handleBackdropClick} > -
+
+ {/* Celebration effects */} + + setShowSuccessToast(false)} + /> {/* Header */}

New Project Wizard

diff --git a/apps/web/src/components/ui/ConfettiBurst.tsx b/apps/web/src/components/ui/ConfettiBurst.tsx new file mode 100644 index 0000000..80b7415 --- /dev/null +++ b/apps/web/src/components/ui/ConfettiBurst.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; + +interface ConfettiBurstProps { + trigger: boolean; + particleCount?: number; + duration?: number; + colors?: string[]; +} + +const DEFAULT_COLORS = [ + "#6366f1", // indigo + "#8b5cf6", // violet + "#ec4899", // pink + "#f59e0b", // amber + "#10b981", // emerald + "#3b82f6", // blue +]; + +const KEYFRAMES_ID = "confetti-burst-keyframes"; + +function ensureKeyframes() { + if (typeof document === "undefined") return; + if (document.getElementById(KEYFRAMES_ID)) return; + const style = document.createElement("style"); + style.id = KEYFRAMES_ID; + style.textContent = ` + @keyframes confetti-burst { + 0% { + transform: translate(0, 0) rotate(0deg) scale(1); + opacity: 1; + } + 100% { + transform: translate(var(--confetti-x), var(--confetti-y)) rotate(var(--confetti-r)) scale(0.3); + opacity: 0; + } + } + `; + document.head.appendChild(style); +} + +export function ConfettiBurst({ + trigger, + particleCount = 20, + duration = 800, + colors = DEFAULT_COLORS, +}: ConfettiBurstProps) { + const containerRef = useRef(null); + const prevTrigger = useRef(false); + + const burst = useCallback(() => { + const container = containerRef.current; + if (!container) return; + + ensureKeyframes(); + + const particles: HTMLDivElement[] = []; + for (let i = 0; i < particleCount; i++) { + const el = document.createElement("div"); + const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5; + const distance = 40 + Math.random() * 80; + const x = Math.cos(angle) * distance; + const y = Math.sin(angle) * distance - 20 + Math.random() * 60; // gravity bias + const rotation = Math.random() * 720 - 360; + + el.style.cssText = ` + position: absolute; + left: 50%; + top: 50%; + width: 6px; + height: 6px; + margin: -3px 0 0 -3px; + border-radius: ${Math.random() > 0.5 ? "50%" : "1px"}; + background: ${colors[i % colors.length]}; + pointer-events: none; + --confetti-x: ${x}px; + --confetti-y: ${y}px; + --confetti-r: ${rotation}deg; + animation: confetti-burst ${duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; + `; + container.appendChild(el); + particles.push(el); + } + + setTimeout(() => { + particles.forEach((el) => el.remove()); + }, duration + 50); + }, [particleCount, duration, colors]); + + useEffect(() => { + if (trigger && !prevTrigger.current) { + burst(); + } + prevTrigger.current = trigger; + }, [trigger, burst]); + + return ( +