diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 59784bf8fac..dcf2c988778 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -17,6 +17,7 @@ import { resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveSidebarV2Status, + resolveSidebarV2StatusSlotClassNames, resolveThreadStatusPill, resolveWorkingStartedAt, formatWorkingDurationLabel, @@ -654,6 +655,55 @@ describe("resolveSidebarV2Status", () => { }); }); +describe("resolveSidebarV2StatusSlotClassNames", () => { + // The label and the actions share one slot, so the states must be + // mutually exclusive: a variant that reveals the actions without fading + // the label leaves "Working" stacked on top of "Settle". + const revealVariants = (className: string): ReadonlyArray => + className + .split(" ") + .filter((candidate) => candidate.endsWith(":opacity-100")) + .map((candidate) => candidate.slice(0, -":opacity-100".length)); + + it("fades the status label out for every variant that reveals the actions", () => { + for (const snoozeMenuOpen of [false, true]) { + const { statusClassName, actionsClassName } = resolveSidebarV2StatusSlotClassNames({ + snoozeMenuOpen, + }); + const variants = revealVariants(actionsClassName); + expect(variants.length).toBeGreaterThan(0); + for (const variant of variants) { + expect(statusClassName).toContain(`${variant}:opacity-0`); + } + } + }); + + it("never takes the status label out of flow while it is still visible", () => { + const { statusClassName } = resolveSidebarV2StatusSlotClassNames({ snoozeMenuOpen: false }); + const outOfFlowVariants = statusClassName + .split(" ") + .filter((candidate) => candidate.endsWith(":absolute")) + .map((candidate) => candidate.slice(0, -":absolute".length)); + + expect(outOfFlowVariants).toContain("group-focus-within/v2-status-slot"); + expect(outOfFlowVariants).toContain("group-hover/v2-row"); + for (const variant of outOfFlowVariants) { + expect(statusClassName).toContain(`${variant}:opacity-0`); + } + }); + + it("swaps both halves while the snooze popover holds the row open", () => { + const { statusClassName, actionsClassName } = resolveSidebarV2StatusSlotClassNames({ + snoozeMenuOpen: true, + }); + + expect(statusClassName).toContain("opacity-0"); + expect(statusClassName).toContain("absolute"); + expect(actionsClassName).toContain("static"); + expect(actionsClassName).toContain("opacity-100"); + }); +}); + describe("sortThreadsForSidebarV2", () => { const sortable = (input: { id: string; createdAt: string }) => ({ id: input.id, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7aee3100d0e..3a5994a3204 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -424,6 +424,29 @@ export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2S return "ready"; } +/** The v2 card's status label and its hover actions share one slot, so they + must never be visible together. Both class strings live here because the + invariant is a pairing, not two independent styles: every variant that + promotes the actions has to fade the label out, and taking the label out + of flow without fading it leaves "Working" stacked on top of Settle. + Written as literals — Tailwind scans source text, so these cannot be + assembled from a shared list of variants. */ +export function resolveSidebarV2StatusSlotClassNames(input: { snoozeMenuOpen: boolean }): { + statusClassName: string; + actionsClassName: string; +} { + return { + statusClassName: cn( + "self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity group-focus-within/v2-status-slot:absolute group-focus-within/v2-status-slot:right-0 group-focus-within/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0", + input.snoozeMenuOpen && "absolute right-0 opacity-0", + ), + actionsClassName: cn( + "absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity group-focus-within/v2-status-slot:static group-focus-within/v2-status-slot:opacity-100 group-hover/v2-row:static group-hover/v2-row:opacity-100", + input.snoozeMenuOpen && "static opacity-100", + ), + }; +} + /** NaN-safe Date.parse for sort comparators: a malformed timestamp must not poison the whole ordering, so it sinks to the epoch instead. */ export function parseTimestampMs(isoDate: string): number { diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index be93f510c97..3ae970d5ba7 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -113,6 +113,7 @@ import { resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarV2Status, + resolveSidebarV2StatusSlotClassNames, resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, @@ -648,6 +649,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { useEffect(() => { if (!showSnoozeButton) setSnoozeMenuOpen(false); }, [showSnoozeButton]); + const statusSlotClassNames = resolveSidebarV2StatusSlotClassNames({ snoozeMenuOpen }); const handlePrClick = useCallback( (event: ReactMouseEvent) => { if (pr?.url) openPrLink(event, pr.url); @@ -890,25 +892,25 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { the hidden state out of flow lets the project label reclaim space without either state overlapping it. */} - + + {/* Baseline, not center: an inline-flex takes its baseline + from the first item, so the leading icon used to drag the + label 2px above the project title and the duration next + to it. The icon opts out with self-center and rides the + text's line box instead. */} {topStatus ? ( {topStatus.icon === "working" ? ( - + ) : topStatus.icon === "done" ? ( - + ) : topStatus.icon === "woke" ? ( - + ) : null} {/* The label alone is the live region: a role="status" wrapper around the ticking duration would make @@ -925,12 +927,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { )} {props.settlementSupported || showSnoozeButton ? ( - + {showSnoozeButton ? (