From d9c205898ef6533b629e985d127c0f2f708b1a93 Mon Sep 17 00:00:00 2001 From: Sy-D <8460326+Sy-D@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:24:47 +0200 Subject: [PATCH] fix(web): stop the status label stacking on the row's settle actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status label and the hover actions share one slot and must never both be visible. The hover branch was symmetric — label fades out, actions fade in — but the focus branch was not: focus-within pulled the label out of flow at right-0 without fading it, so it stayed fully opaque on top of the actions. Focus is sticky, which is why this looked intermittent and pointer-driven. Pair both class strings in one helper so the invariant cannot drift again, and cover it with tests that assert the pairing rather than the literal strings. Also align the label to the row: an inline-flex takes its baseline from its first item, and the leading icon has none, so it was setting the baseline and lifting "Working" 2px above the project title and the duration. The text run aligns on the baseline and the icon opts out with self-center. Closes #4817. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/Sidebar.logic.test.ts | 50 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 23 +++++++++ apps/web/src/components/SidebarV2.tsx | 29 +++++------ 3 files changed, 86 insertions(+), 16 deletions(-) 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 ? (