mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 16:02:02 +00:00
Merge remote-tracking branch 'origin/main' into fix/warmup-removal-verification
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<NonNullable<NavItem["requires"]>, 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.
|
||||
<TooltipGroupRoot>
|
||||
<TooltipGroupRoot open={collapsed && open} onOpenChange={setOpen}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
{collapsed && (
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</TooltipGroupRoot>
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
)}
|
||||
>
|
||||
<LockIcon className="w-[13px] h-[13px] shrink-0 text-slate-300 group-hover:text-slate-500" strokeWidth={1.8} />
|
||||
{/* 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. */}
|
||||
<span className={collapsed ? "sr-only" : "truncate flex-1 min-w-0 text-left"}>
|
||||
{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. */}
|
||||
<span className={labelFade(collapsed)}>
|
||||
<span className="truncate flex-1 min-w-0 text-left">{item.title}</span>
|
||||
{collapsed && <span className="sr-only"> · no access</span>}
|
||||
</span>
|
||||
</button>
|
||||
</NavTip>
|
||||
@@ -286,10 +311,8 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea
|
||||
)}
|
||||
>
|
||||
<LockIcon className="w-[13px] h-[13px] shrink-0 text-slate-300 group-hover:text-slate-500" strokeWidth={1.8} />
|
||||
<span className={collapsed ? "sr-only" : "truncate flex-1 min-w-0 text-left"}>
|
||||
{collapsed ? `${item.title} · ${planBadge.label} plan` : item.title}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className={labelFade(collapsed)}>
|
||||
<span className="truncate flex-1 min-w-0 text-left">{item.title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-4 px-1.5 rounded text-[9.5px] font-semibold uppercase tracking-[0.06em] border inline-flex items-center",
|
||||
@@ -297,8 +320,9 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea
|
||||
)}
|
||||
>
|
||||
{planBadge.label}
|
||||
{collapsed && <span className="sr-only"> plan</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</NavTip>
|
||||
);
|
||||
@@ -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 (
|
||||
<NavTip collapsed label={item.title}>
|
||||
<Link
|
||||
to={item.url}
|
||||
className={cn(
|
||||
ICON_ROW,
|
||||
active
|
||||
? "bg-slate-200/70 text-slate-900"
|
||||
: locked
|
||||
? "text-slate-400 hover:text-slate-700 hover:bg-slate-200/40"
|
||||
: "text-slate-600 hover:text-slate-900 hover:bg-slate-200/40",
|
||||
)}
|
||||
>
|
||||
{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". */}
|
||||
<span className="sr-only">{item.title}</span>
|
||||
{item.advisorSurface && !locked && (
|
||||
<AdvisorNavBadge surface={item.advisorSurface} dot />
|
||||
)}
|
||||
{badge != null && badge > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 min-w-[15px] h-[15px] px-1 rounded-full bg-red-500 text-white text-[9px] font-medium leading-none flex items-center justify-center tabular-nums ring-2 ring-white">
|
||||
<span className="sr-only">{badge} unread</span>
|
||||
<span aria-hidden>{badge > 9 ? "9+" : badge}</span>
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</NavTip>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Link
|
||||
to={item.url}
|
||||
title={planBadge ? `${item.title} · ${planBadge.label} plan` : undefined}
|
||||
className={cn(
|
||||
LABEL_ROW,
|
||||
active
|
||||
? "bg-slate-200/70 text-slate-900 font-medium"
|
||||
: locked
|
||||
? "text-slate-400 hover:text-slate-700 hover:bg-slate-200/40"
|
||||
: "text-slate-600 hover:text-slate-900 hover:bg-slate-200/40",
|
||||
)}
|
||||
>
|
||||
{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. */}
|
||||
<span className="truncate flex-1 min-w-0">{item.title}</span>
|
||||
{item.advisorSurface && !locked && <AdvisorNavBadge surface={item.advisorSurface} />}
|
||||
{item.indicator === "campaigns" && !locked && <CampaignActivity />}
|
||||
{item.indicator === "accounts" && !locked && <MailboxActivity />}
|
||||
{item.indicator === "tasks" && !locked && <TasksActivity />}
|
||||
{item.indicator === "meetings" && !locked && <MeetingsActivity />}
|
||||
{item.indicator === "contacts" && !locked && <ContactsActivity />}
|
||||
{item.indicator === "deals" && !locked && <DealsActivity />}
|
||||
{item.indicator === "pipelines" && !locked && <PipelinesActivity />}
|
||||
{item.indicator === "templates" && !locked && <TemplatesActivity />}
|
||||
{item.indicator === "analytics" && !locked && <AnalyticsActivity />}
|
||||
{item.indicator === "apikeys" && !locked && <ApiKeysActivity />}
|
||||
{item.indicator === "integrations" && !locked && <IntegrationsActivity />}
|
||||
{planBadge ? (
|
||||
<span
|
||||
className={cn(
|
||||
"h-4 px-1.5 rounded text-[9.5px] font-semibold uppercase tracking-[0.06em] border inline-flex items-center",
|
||||
planBadge.classes,
|
||||
<NavTip collapsed={collapsed} label={item.title}>
|
||||
<Link
|
||||
to={item.url}
|
||||
aria-current={active ? "page" : undefined}
|
||||
title={!collapsed && planBadge ? `${item.title} · ${planBadge.label} plan` : undefined}
|
||||
className={cn(
|
||||
rowClass(collapsed),
|
||||
active
|
||||
? cn("bg-slate-200/70 text-slate-900", !collapsed && "font-medium")
|
||||
: locked
|
||||
? "text-slate-400 hover:text-slate-700 hover:bg-slate-200/40"
|
||||
: "text-slate-600 hover:text-slate-900 hover:bg-slate-200/40",
|
||||
)}
|
||||
>
|
||||
{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. */}
|
||||
<span className={labelFade(collapsed)}>
|
||||
<span className="truncate flex-1 min-w-0">{item.title}</span>
|
||||
{item.advisorSurface && !locked && !collapsed && <AdvisorNavBadge surface={item.advisorSurface} />}
|
||||
{item.indicator === "campaigns" && !locked && <CampaignActivity />}
|
||||
{item.indicator === "accounts" && !locked && <MailboxActivity />}
|
||||
{item.indicator === "tasks" && !locked && <TasksActivity />}
|
||||
{item.indicator === "meetings" && !locked && <MeetingsActivity />}
|
||||
{item.indicator === "contacts" && !locked && <ContactsActivity />}
|
||||
{item.indicator === "deals" && !locked && <DealsActivity />}
|
||||
{item.indicator === "pipelines" && !locked && <PipelinesActivity />}
|
||||
{item.indicator === "templates" && !locked && <TemplatesActivity />}
|
||||
{item.indicator === "analytics" && !locked && <AnalyticsActivity />}
|
||||
{item.indicator === "apikeys" && !locked && <ApiKeysActivity />}
|
||||
{item.indicator === "integrations" && !locked && <IntegrationsActivity />}
|
||||
{planBadge ? (
|
||||
<span
|
||||
className={cn(
|
||||
"h-4 px-1.5 rounded text-[9.5px] font-semibold uppercase tracking-[0.06em] border inline-flex items-center",
|
||||
planBadge.classes,
|
||||
)}
|
||||
>
|
||||
{planBadge.label}
|
||||
</span>
|
||||
) : (
|
||||
!collapsed && badge != null && badge > 0 && (
|
||||
<span className="text-[10px] font-medium bg-red-500 text-white rounded-full min-w-[16px] h-4 flex items-center justify-center px-1 tabular-nums">
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
>
|
||||
{planBadge.label}
|
||||
</span>
|
||||
) : (
|
||||
badge != null && badge > 0 && (
|
||||
<span className="text-[10px] font-medium bg-red-500 text-white rounded-full min-w-[16px] h-4 flex items-center justify-center px-1 tabular-nums">
|
||||
{badge > 99 ? "99+" : badge}
|
||||
{collapsed && item.advisorSurface && !locked && (
|
||||
<span className={cn("pointer-events-none absolute inset-0", RAIL_MARK_IN)}>
|
||||
<AdvisorNavBadge surface={item.advisorSurface} dot />
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
{collapsed && badge != null && badge > 0 && (
|
||||
<span className={cn("absolute -right-0.5 -top-0.5 min-w-[15px] h-[15px] px-1 rounded-full bg-red-500 text-white text-[9px] font-medium leading-none flex items-center justify-center tabular-nums ring-2 ring-white", RAIL_MARK_IN)}>
|
||||
<span className="sr-only">{badge} unread</span>
|
||||
<span aria-hidden>{badge > 9 ? "9+" : badge}</span>
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</NavTip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
title={label}
|
||||
className={cn("size-1.5 shrink-0 rounded-full", critical > 0 ? "bg-rose-500" : "bg-orange-500")}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={first ? "" : "mt-4 pt-4 border-t border-slate-200/50"}>
|
||||
{/* Collapsed, the hairline above the group carries the grouping on
|
||||
its own — a tracked-uppercase label does not fit in 56px. */}
|
||||
{!collapsed && (
|
||||
<div className="px-4 mb-1.5">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
|
||||
{section.label}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={
|
||||
gone
|
||||
? { height: 0, paddingTop: 0, opacity: 0, overflow: "hidden" }
|
||||
: { height: "auto", paddingTop: gap, opacity: 1, transitionEnd: { overflow: "visible" } }
|
||||
}
|
||||
transition={transition}
|
||||
inert={gone}
|
||||
>
|
||||
<div className={first ? "" : "pt-4 border-t border-slate-200/50"}>
|
||||
{/* 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. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!folded}
|
||||
aria-controls={id}
|
||||
onClick={() => toggleNavSection(section.id)}
|
||||
inert={collapsed}
|
||||
className={cn(
|
||||
"group/section mx-2 flex w-[calc(100%-1rem)] items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-md px-2 text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400 transition-[height,margin,opacity,color] duration-200 ease-out hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 motion-reduce:transition-none",
|
||||
collapsed ? "mb-0 h-0 opacity-0" : "mb-1 h-6 opacity-100",
|
||||
)}
|
||||
>
|
||||
<span>{section.label}</span>
|
||||
{/* Always shown while folded so the state reads at a glance;
|
||||
expanded it appears on hover (always on touch). */}
|
||||
<ChevronDownIcon
|
||||
aria-hidden
|
||||
strokeWidth={2}
|
||||
className={cn(
|
||||
"size-3 shrink-0 transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none",
|
||||
folded
|
||||
? "-rotate-90 opacity-100"
|
||||
: "opacity-100 md:opacity-0 md:group-hover/section:opacity-100 md:group-focus-visible/section:opacity-100",
|
||||
)}
|
||||
/>
|
||||
<span className="ml-auto flex items-center">
|
||||
<AnimatePresence initial={false}>
|
||||
{folded && hiddenSurfaces.length > 0 && (
|
||||
<motion.span
|
||||
key="attention"
|
||||
className="flex"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={transition}
|
||||
>
|
||||
<FoldedAdvisorDot surfaces={hiddenSurfaces} />
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
</button>
|
||||
<div id={id} className="space-y-px">
|
||||
{/* Each row folds its own height, so the rows around the one
|
||||
you are on close in on it instead of the block snapping. */}
|
||||
<AnimatePresence initial={false}>
|
||||
{shown.map((it) => (
|
||||
<motion.div
|
||||
key={it.url}
|
||||
initial={{ height: 0, opacity: 0, overflow: "hidden" }}
|
||||
animate={{ height: "auto", opacity: 1, transitionEnd: { overflow: "visible" } }}
|
||||
exit={{ height: 0, opacity: 0, overflow: "hidden" }}
|
||||
transition={transition}
|
||||
>
|
||||
<NavRow item={it} collapsed={collapsed} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-px">
|
||||
{section.items.map((it) => (
|
||||
<NavRow key={it.url} item={it} collapsed={collapsed} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState<number | "auto">("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 (
|
||||
<motion.div initial={false} animate={{ height }} transition={ease} className="shrink-0 overflow-hidden">
|
||||
<div ref={inner} className="relative flow-root">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
<motion.div
|
||||
key={collapsed ? "rail" : "full"}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: reduceMotion ? { duration: 0 } : { duration: 0.15, delay: 0.08 } }}
|
||||
exit={{ opacity: 0, transition: reduceMotion ? { duration: 0 } : { duration: 0.1 } }}
|
||||
>
|
||||
<LivePanel collapsed={collapsed} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
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?: ()
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LivePanel collapsed={iconOnly} />
|
||||
<LivePanelSlot collapsed={iconOnly} />
|
||||
|
||||
{/* 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. */}
|
||||
<nav className={cn("flex-1 overflow-y-auto overflow-x-hidden pb-3", iconOnly && "pt-1")}>
|
||||
<nav className={cn("flex-1 overflow-y-auto overflow-x-hidden pb-3 transition-[padding] duration-200 ease-out", iconOnly && "pt-1")}>
|
||||
<div className="space-y-px">
|
||||
{topItems.map((it) => (
|
||||
<NavRow key={it.url + it.title} item={it} collapsed={iconOnly} />
|
||||
@@ -1143,7 +1297,7 @@ export function AppNav({ open = false, onClose }: { open?: boolean; onClose?: ()
|
||||
</div>
|
||||
{sections.map((s, i) => (
|
||||
<Section
|
||||
key={s.label}
|
||||
key={s.id}
|
||||
section={s}
|
||||
first={i === 0 && topItems.length === 0}
|
||||
collapsed={iconOnly}
|
||||
@@ -1205,9 +1359,9 @@ function CollapseToggle({
|
||||
) : (
|
||||
<PanelLeftCloseIcon className="w-[14px] h-[14px] shrink-0 text-slate-400 group-hover:text-slate-600" strokeWidth={1.6} />
|
||||
)}
|
||||
{!collapsed && (
|
||||
<span className={labelFade(collapsed)}>
|
||||
<span className="truncate flex-1 min-w-0 text-left">Collapse</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</ShortcutTooltip>
|
||||
);
|
||||
|
||||
@@ -46,11 +46,13 @@ export function UserNav({ collapsed = false }: { collapsed?: boolean }) {
|
||||
<PopoverMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={collapsed ? displayName : undefined}
|
||||
// One element in both shapes so it eases with the sidebar's
|
||||
// width: the avatar drifts to the rail's centre, the name fades.
|
||||
className={cn(
|
||||
"flex items-center rounded-md hover:bg-slate-200/40 transition-colors cursor-pointer",
|
||||
"flex items-center mx-3 my-2 rounded-md hover:bg-slate-200/40 cursor-pointer transition-[width,padding,gap,background-color] duration-200 ease-out motion-reduce:transition-none",
|
||||
collapsed
|
||||
? "mx-auto my-2 size-8 justify-center"
|
||||
: "gap-2.5 mx-3 my-2 px-1.5 py-1 w-[calc(100%-1.5rem)]",
|
||||
? "w-8 gap-0 p-0.5"
|
||||
: "w-[calc(100%-1.5rem)] gap-2.5 px-1.5 py-1",
|
||||
)}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-slate-900 flex items-center justify-center shrink-0 overflow-hidden">
|
||||
@@ -66,16 +68,20 @@ export function UserNav({ collapsed = false }: { collapsed?: boolean }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="text-[13px] text-slate-900 truncate">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-[10.5px] text-slate-500 truncate">
|
||||
{user.email}
|
||||
</div>
|
||||
<div
|
||||
aria-hidden={collapsed || undefined}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 overflow-hidden whitespace-nowrap text-left transition-opacity ease-out motion-reduce:transition-none",
|
||||
collapsed ? "opacity-0 duration-100" : "opacity-100 duration-200 delay-75",
|
||||
)}
|
||||
>
|
||||
<div className="text-[13px] text-slate-900 truncate">
|
||||
{displayName}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10.5px] text-slate-500 truncate">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverMenuTrigger>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export function checkPermission(key: PermissionKey): boolean {
|
||||
return orgHasPermission(useAppStore.getState().currentOrganization, key);
|
||||
}
|
||||
|
||||
function orgHasPermission(
|
||||
export function orgHasPermission(
|
||||
org: { role?: string; permissions?: number } | null | undefined,
|
||||
key: PermissionKey,
|
||||
): boolean {
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface UISlice {
|
||||
// and every store written before this had no version field at all.
|
||||
navCollapsed: boolean
|
||||
sidebarMobileOpen: boolean
|
||||
// Folded sidebar sections, keyed by the section's stable id (not its label).
|
||||
navCollapsedSections: Record<string, boolean>
|
||||
|
||||
// Theme
|
||||
theme: Theme
|
||||
@@ -55,6 +57,7 @@ export interface UISlice {
|
||||
toggleSidebar: () => void
|
||||
setSidebarCollapsed: (collapsed: boolean) => void
|
||||
setSidebarMobileOpen: (open: boolean) => void
|
||||
toggleNavSection: (id: string) => void
|
||||
|
||||
// Actions - Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
@@ -79,6 +82,13 @@ const getInitialTheme = (): Theme => {
|
||||
return (localStorage.getItem('theme') as Theme) || 'system'
|
||||
}
|
||||
|
||||
// Rehydration bypasses the setter, so a stored value that is not a map of
|
||||
// booleans (older build, hand edit) falls back to everything expanded.
|
||||
export const sanitizeNavCollapsedSections = (v: unknown): Record<string, boolean> => {
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return {}
|
||||
return Object.fromEntries(Object.entries(v).filter(([, folded]) => typeof folded === 'boolean'))
|
||||
}
|
||||
|
||||
// The dashboard is light-only today: every surface is styled on white, so a
|
||||
// resolved dark theme would flip only the CSS-variable components (command
|
||||
// palette, toasts) and look broken. 'dark'/'system' are accepted but resolve
|
||||
@@ -91,6 +101,7 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
|
||||
// Sidebar
|
||||
navCollapsed: false,
|
||||
sidebarMobileOpen: false,
|
||||
navCollapsedSections: {},
|
||||
|
||||
// Theme
|
||||
theme: getInitialTheme(),
|
||||
@@ -115,6 +126,11 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
|
||||
setSidebarMobileOpen: (sidebarMobileOpen) =>
|
||||
set((state) => (state.sidebarMobileOpen === sidebarMobileOpen ? state : { sidebarMobileOpen })),
|
||||
|
||||
toggleNavSection: (id) =>
|
||||
set((state) => ({
|
||||
navCollapsedSections: { ...state.navCollapsedSections, [id]: !state.navCollapsedSections[id] },
|
||||
})),
|
||||
|
||||
// Actions - Theme
|
||||
setTheme: (theme) => {
|
||||
if (get().theme === theme) return
|
||||
|
||||
@@ -3,7 +3,7 @@ import { devtools, persist } from 'zustand/middleware'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { createUserSlice, type UserSlice } from './slices/userSlice'
|
||||
import { createOrganizationSlice, type OrganizationSlice } from './slices/organizationSlice'
|
||||
import { createUISlice, clampUniboxListWidth, type UISlice } from './slices/uiSlice'
|
||||
import { createUISlice, clampUniboxListWidth, sanitizeNavCollapsedSections, type UISlice } from './slices/uiSlice'
|
||||
import { createShortcutSlice, type ShortcutSlice } from './slices/shortcutSlice'
|
||||
import { createDataSlice, type DataSlice } from './slices/dataSlice'
|
||||
import { createRealtimeSlice, type RealtimeSlice } from './slices/realtimeSlice'
|
||||
@@ -47,12 +47,14 @@ export const useAppStore = create<AppStore>()(
|
||||
...current,
|
||||
...p,
|
||||
uniboxListWidth: clampUniboxListWidth(p.uniboxListWidth),
|
||||
navCollapsedSections: sanitizeNavCollapsedSections(p.navCollapsedSections),
|
||||
}
|
||||
},
|
||||
partialize: (state) => ({
|
||||
// Only persist UI preferences
|
||||
theme: state.theme,
|
||||
navCollapsed: state.navCollapsed,
|
||||
navCollapsedSections: state.navCollapsedSections,
|
||||
// Assistant panel layout (edge + width + floating window geometry)
|
||||
agentSide: state.agentSide,
|
||||
agentWidth: state.agentWidth,
|
||||
|
||||
Reference in New Issue
Block a user