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 (
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* 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 (
<>
- |