diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index a01560cfd..a2735f1bc 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -15,6 +15,7 @@ The columns are yours to size. Every choice here is remembered in the browser yo | --- | --- | | The divider between the list and the thread | Drag it to widen the conversation list so long subjects fit, or to give the space back to the thread. It also takes the keyboard once it has focus: arrow keys nudge it (hold `Shift` for a bigger step), `Home` and `End` go to the narrowest and widest the window allows, `Enter` (or a double-click) puts it back to the default. | | **Collapse** at the bottom of the left navigation, or `b` | Shrinks Warmbly's own navigation to an icon rail and gives the width to the page. Row labels become tooltips; unread mail keeps its count badge and an open [Advisor](/guides/advisor/) finding shows as a coloured dot on the icon. The same control expands it again. | +| The section headers in the left navigation (Email, CRM, Resources) | Click one to fold its section away, and again to bring it back. Like the other layout choices, it is remembered in this browser. A folded section still shows the page you are on, and a coloured dot beside its header flags an open [Advisor](/guides/advisor/) finding on one of the pages it hides. The icon rail follows the same choice. | | The contact button in the thread header | Shows or hides the contact panel on the right. It starts closed when no preference is saved. Opening or closing it sticks across conversations, and existing preferences are preserved. | How wide the list can get depends on the window: the thread always keeps enough room to read a message, and the contact panel counts toward that when it is open, so the widest setting is narrower on a laptop than on a large monitor. diff --git a/web/src/app/app/unibox/uniboxLayout.test.tsx b/web/src/app/app/unibox/uniboxLayout.test.tsx index 956f9ccea..f2d66a65e 100644 --- a/web/src/app/app/unibox/uniboxLayout.test.tsx +++ b/web/src/app/app/unibox/uniboxLayout.test.tsx @@ -175,14 +175,14 @@ describe("unibox desktop layout (#473)", SUITE, () => { expect(useAppStore.getState().navCollapsed).toBe(true); expect(aside.className).toContain("md:w-14"); - // Same destination, no VISIBLE label: the name moves into a - // visually hidden span, because lucide marks its svg aria-hidden - // and the link would otherwise announce as nothing at all. It must - // not become an aria-label: that would override the whole subtree - // and silence the unread count nested in the same link. + // Same destination, no VISIBLE label: the label column is faded and + // clipped but stays in the link, because lucide marks its svg + // aria-hidden and the link would otherwise announce as nothing at + // all. It must not become an aria-label: that would override the + // whole subtree and silence the unread count nested in the same link. expect(settingsLink().getAttribute("aria-label")).toBeNull(); expect(settingsLink().textContent).toBe("Settings"); - expect(settingsLink().querySelector("span")?.className).toContain("sr-only"); + expect(settingsLink().querySelector("span")?.className).toContain("opacity-0"); // `b` is the documented shortcut for the same thing. It was wired to // the store while nothing rendered from it; this is what makes it diff --git a/web/src/components/layout/AppNav.tsx b/web/src/components/layout/AppNav.tsx index f88467465..0d0ea550e 100644 --- a/web/src/components/layout/AppNav.tsx +++ b/web/src/components/layout/AppNav.tsx @@ -13,6 +13,7 @@ import { CableIcon, CalendarClockIcon, CheckSquareIcon, + ChevronDownIcon, CircleDollarSignIcon, FileTextIcon, FlameIcon, @@ -33,10 +34,11 @@ import { XIcon, ZapIcon, } from "lucide-react"; -import { type ReactElement, type ReactNode, useMemo, useState } from "react"; +import { type ReactElement, type ReactNode, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { useAppStore } from "@/stores"; import useFeatureAccess from "@/hooks/useFeatureAccess"; -import { usePermission, type PermissionKey } from "@/hooks/usePermission"; +import { orgHasPermission, usePermission, type PermissionKey } from "@/hooks/usePermission"; import { useUpgradeDialog } from "@/hooks/context/upgrade"; import { PLAN_ACCENT_CLASSES, getPlan, type PlanID } from "@/lib/plans"; import AccessLockedDialog from "./AccessLockedDialog"; @@ -58,6 +60,7 @@ import useAPIKeys from "@/lib/api/hooks/app/api-keys/useAPIKeys"; import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections"; import AnimatedNumber from "@/components/ui/AnimatedNumber"; import AdvisorNavBadge from "@/components/app/advisor/AdvisorNavBadge"; +import { useAdvisorSummary } from "@/lib/api/hooks/app/advisor/useAdvisor"; import type { AdvisorSurface } from "@/lib/api/models/app/advisor/Advisor"; import { UserNav } from "./UserNav"; import { Logo } from "@/components/svg"; @@ -126,6 +129,8 @@ const REQUIRES_TO_MIN_PLAN: Record, PlanID> = { }; interface NavSection { + /** Stable key for the persisted fold state, so renaming a label keeps it. */ + id: string; label: string; items: NavItem[]; } @@ -144,6 +149,7 @@ const topItems: NavItem[] = [ const sections: NavSection[] = [ { + id: "email", label: "Email", items: [ { title: "Accounts", url: "/app/emails", icon: MailIcon, indicator: "accounts", advisorSurface: "emails", permission: "MANAGE_EMAILS", permissionLabel: "Manage mailboxes" }, @@ -156,6 +162,7 @@ const sections: NavSection[] = [ ], }, { + id: "crm", label: "CRM", items: [ { title: "Pipelines", requires: "subscription", url: "/app/crm/pipelines", icon: GitBranchIcon, indicator: "pipelines", permission: "VIEW_CONTACTS", permissionLabel: "View contacts" }, @@ -165,6 +172,7 @@ const sections: NavSection[] = [ ], }, { + id: "resources", label: "Resources", items: [ { title: "Templates", requires: "subscription", url: "/app/templates", icon: FileTextIcon, indicator: "templates" }, @@ -176,8 +184,9 @@ const sections: NavSection[] = [ }, ]; -// NavTip wraps a collapsed rail row in the themed tooltip, so an icon-only -// row still says what it is. Expanded rows carry their label and need none. +// NavTip wraps a rail row in the themed tooltip, so an icon-only row still +// says what it is. The trigger stays mounted in both modes (only the content +// is conditional) so a row is never remounted and can animate between them. function NavTip({ collapsed, label, @@ -187,26 +196,44 @@ function NavTip({ label: string; children: ReactElement; }) { - if (!collapsed) return children; + // Controlled, so a hover in the expanded sidebar never opens a tip there. + const [open, setOpen] = useState(false); return ( // Rooted in the rail's shared provider: after the first tip, moving to // the next row shows its name at once instead of waiting again. - + {children} - - {label} - + {collapsed && ( + + {label} + + )} ); } -// The two row shapes. Collapsed, the rail is 56px wide, so a row is a centred -// 32px icon target; expanded it is a full-width label row. Both are constants -// because four call sites branch between them. -const ICON_ROW = "group relative mx-auto flex size-8 items-center justify-center rounded-md transition-colors duration-100"; -const LABEL_ROW = "group relative mx-2 w-[calc(100%-1rem)] flex items-center gap-2.5 px-2.5 h-7 rounded-md text-[12.5px] transition-colors duration-100"; +// The two row shapes share one element and transition between each other in +// step with the sidebar's width: the icon holds its place (it drifts 3px into +// the rail's centre) while the label column fades and is clipped. +const ROW_BASE = "group relative flex items-center rounded-md text-[12.5px] transition-[margin,width,height,padding,gap,background-color,color] duration-200 ease-out motion-reduce:transition-none"; +const ICON_ROW = `${ROW_BASE} mx-3 w-8 h-8 px-[9px] gap-0`; +const LABEL_ROW = `${ROW_BASE} mx-2 w-[calc(100%-1rem)] h-7 px-2.5 gap-2.5`; const rowClass = (collapsed: boolean) => (collapsed ? ICON_ROW : LABEL_ROW); +// Fades out fast on collapse, and back in once the column has room again. +const labelFade = (collapsed: boolean) => + cn( + "flex min-w-0 flex-1 items-center gap-2.5 overflow-hidden whitespace-nowrap transition-opacity ease-out motion-reduce:transition-none", + collapsed ? "opacity-0 duration-100" : "opacity-100 duration-200 delay-75", + ); + +// Rail-only marks (the unread count, an Advisor dot) fade in as the rail settles. +const RAIL_MARK_IN = "animate-in fade-in-0 zoom-in-50 duration-200 delay-100 fill-mode-both motion-reduce:animate-none"; + +function isNavItemActive(pathname: string, item: NavItem): boolean { + return pathname === item.url || pathname.startsWith(item.url + "/"); +} + function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolean }) { const { pathname } = useLocation(); const unseen = useAppStore((s) => s.unseenCount); @@ -214,8 +241,7 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea const hasItemPermission = usePermission(item.permission ?? "VIEW_CAMPAIGNS"); const [deniedOpen, setDeniedOpen] = useState(false); const upgradeDialog = useUpgradeDialog(); - const active = - pathname === item.url || pathname.startsWith(item.url + "/"); + const active = isNavItemActive(pathname, item); const badge = item.badgeStoreKey === "unseenCount" ? unseen : undefined; // Role-gated items disappear from the sidebar for users that @@ -240,12 +266,11 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea )} > - {/* Collapsed, lucide marks its svg aria-hidden, so the name - comes from a visually hidden span. NOT aria-label: that - would override the whole subtree, silencing the badges - the collapsed rail exists to keep. */} - - {collapsed ? `${item.title} · no access` : item.title} + {/* The label stays in the tree when collapsed, so it names + the row for a screen reader while the tooltip shows it. */} + + {item.title} + {collapsed && · no access} @@ -286,10 +311,8 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea )} > - - {collapsed ? `${item.title} · ${planBadge.label} plan` : item.title} - - {!collapsed && ( + + {item.title} {planBadge.label} + {collapsed && plan} - )} + ); @@ -318,90 +342,72 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea /> ); - // Collapsed rail: the label and the ambient count clusters have nowhere to - // go, so the row keeps its icon plus the two signals worth interrupting - // for: the unread count, and an Advisor finding as a severity dot. - if (collapsed) { - return ( - - - {icon} - {/* The name is a hidden span rather than an aria-label so it - composes with the count below it: an aria-label on the - link would replace the whole subtree and announce - "Inbox" where the expanded row announces "Inbox 12". */} - {item.title} - {item.advisorSurface && !locked && ( - - )} - {badge != null && badge > 0 && ( - - {badge} unread - {badge > 9 ? "9+" : badge} - - )} - - - ); - } - + // One element for both shapes. Collapsed, the label column (and the + // ambient count clusters in it) is clipped away, and the row keeps the two + // signals worth interrupting for: the unread count and an Advisor dot. return ( - - {icon} - {/* min-w-0 lets the label shrink/truncate so the count cluster (and its - separator) is never pushed off the row — longer labels like - "Campaigns"/"Accounts" used to clip it at narrower widths. */} - {item.title} - {item.advisorSurface && !locked && } - {item.indicator === "campaigns" && !locked && } - {item.indicator === "accounts" && !locked && } - {item.indicator === "tasks" && !locked && } - {item.indicator === "meetings" && !locked && } - {item.indicator === "contacts" && !locked && } - {item.indicator === "deals" && !locked && } - {item.indicator === "pipelines" && !locked && } - {item.indicator === "templates" && !locked && } - {item.indicator === "analytics" && !locked && } - {item.indicator === "apikeys" && !locked && } - {item.indicator === "integrations" && !locked && } - {planBadge ? ( - + + {icon} + {/* min-w-0 lets the label shrink/truncate so the count cluster (and its + separator) is never pushed off the row — longer labels like + "Campaigns"/"Accounts" used to clip it at narrower widths. */} + + {item.title} + {item.advisorSurface && !locked && !collapsed && } + {item.indicator === "campaigns" && !locked && } + {item.indicator === "accounts" && !locked && } + {item.indicator === "tasks" && !locked && } + {item.indicator === "meetings" && !locked && } + {item.indicator === "contacts" && !locked && } + {item.indicator === "deals" && !locked && } + {item.indicator === "pipelines" && !locked && } + {item.indicator === "templates" && !locked && } + {item.indicator === "analytics" && !locked && } + {item.indicator === "apikeys" && !locked && } + {item.indicator === "integrations" && !locked && } + {planBadge ? ( + + {planBadge.label} + + ) : ( + !collapsed && badge != null && badge > 0 && ( + + {badge > 99 ? "99+" : badge} + + ) )} - > - {planBadge.label} - ) : ( - badge != null && badge > 0 && ( - - {badge > 99 ? "99+" : badge} + {collapsed && item.advisorSurface && !locked && ( + + - ) - )} - + )} + {collapsed && badge != null && badge > 0 && ( + + {badge} unread + {badge > 9 ? "9+" : badge} + + )} + + ); } @@ -676,6 +682,31 @@ function IntegrationsActivity() { ); } +// The same urgency the rows badge, summed over the rows a folded section hides, +// so folding Email cannot bury a critical deliverability finding. +function FoldedAdvisorDot({ surfaces }: { surfaces: AdvisorSurface[] }) { + const { data } = useAdvisorSummary(surfaces.length > 0); + let critical = 0; + let urgent = 0; + for (const entry of data?.surfaces ?? []) { + if (!surfaces.includes(entry.surface)) continue; + critical += entry.critical; + urgent += entry.critical + entry.high; + } + if (urgent === 0) return null; + const label = `${urgent} ${urgent === 1 ? "issue" : "issues"} needing attention in this section`; + return ( + 0 ? "bg-rose-500" : "bg-orange-500")} + > + {label} + + ); +} + +const FOLD_EASE = [0.2, 0, 0, 1] as const; + function Section({ section, first = false, @@ -685,23 +716,112 @@ function Section({ first?: boolean; collapsed?: boolean; }) { + const id = useId(); + const { pathname } = useLocation(); + const folded = useAppStore((s) => s.navCollapsedSections[section.id] ?? false); + const toggleNavSection = useAppStore((s) => s.toggleNavSection); + const org = useAppStore((s) => s.currentOrganization); + const access = useFeatureAccess(); + const reduceMotion = useReducedMotion(); + + // Folded, a section keeps only the row you are on, in the rail and the + // full sidebar alike, so where you are never folds away with the rest. + const permitted = section.items.filter((item) => item.rolesAllowed !== "manage" || access.canManage); + const shown = folded ? permitted.filter((item) => isNavItemActive(pathname, item)) : permitted; + const hiddenSurfaces = folded + ? permitted.flatMap((item) => + !isNavItemActive(pathname, item) && + item.advisorSurface && + !(item.requires === "subscription" && access.locked) && + (!item.permission || orgHasPermission(org, item.permission)) + ? [item.advisorSurface] + : [], + ) + : []; + + // In the rail a folded section with nothing left to show goes, divider and all. + const gone = collapsed && shown.length === 0; + const transition = reduceMotion ? { duration: 0 } : { duration: 0.22, ease: FOLD_EASE }; + + // The gap above the divider is animated padding rather than a margin, so + // it folds away with the section instead of collapsing through it. + const gap = first ? 0 : 16; + return ( -
- {/* Collapsed, the hairline above the group carries the grouping on - its own — a tracked-uppercase label does not fit in 56px. */} - {!collapsed && ( -
- - {section.label} + +
+ {/* Collapsed, the hairline above the group carries the grouping + on its own: a tracked-uppercase label does not fit in 56px, + so the header shrinks away with the sidebar's width. */} + +
+ {/* Each row folds its own height, so the rows around the one + you are on close in on it instead of the block snapping. */} + + {shown.map((it) => ( + + + + ))} +
- )} -
- {section.items.map((it) => ( - - ))}
-
+
); } @@ -733,6 +853,40 @@ function Section({ * the mailboxes can send today under the scheduler's clamps, not their caps * added up. */ +// The panel's two shapes share nothing, so they cross-fade while the slot +// eases to the incoming one's height instead of snapping to it. +function LivePanelSlot({ collapsed }: { collapsed: boolean }) { + const reduceMotion = useReducedMotion(); + const inner = useRef(null); + const [height, setHeight] = useState("auto"); + + useLayoutEffect(() => { + const el = inner.current; + if (!el) return; + const observer = new ResizeObserver(() => setHeight(el.offsetHeight)); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const ease = reduceMotion ? { duration: 0 } : { duration: 0.2, ease: FOLD_EASE }; + return ( + +
+ + + + + +
+
+ ); +} + function LivePanel({ collapsed = false }: { collapsed?: boolean }) { const emails = useAppStore((s) => s.emails); const unseenCount = useAppStore((s) => s.unseenCount); @@ -1128,14 +1282,14 @@ export function AppNav({ open = false, onClose }: { open?: boolean; onClose?: ()
- + {/* overflow-x-hidden: mid-animation the rail is narrower than the expanded rows still laid out inside it, and without this the column grows a horizontal scrollbar for those 200ms. */} {/* Collapsed, pt-1 leaves room for the unread badge that sits above the first row's corner, which the scroller would clip. */} -