Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
resolveSidebarStageBadgeLabel,
resolveThreadRowClassName,
resolveSidebarV2Status,
resolveSidebarV2StatusSlotClassNames,
resolveThreadStatusPill,
resolveWorkingStartedAt,
formatWorkingDurationLabel,
Expand Down Expand Up @@ -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<string> =>
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,
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 13 additions & 16 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import {
resolveAdjacentThreadId,
resolveSettledTimestamp,
resolveSidebarV2Status,
resolveSidebarV2StatusSlotClassNames,
resolveWorkingStartedAt,
shouldNavigateAfterProjectRemoval,
sortLogicalProjectsForSidebar,
Expand Down Expand Up @@ -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<HTMLElement>) => {
if (pr?.url) openPrLink(event, pr.url);
Expand Down Expand Up @@ -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. */}
<span className="group/v2-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
<span
className={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-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
snoozeMenuOpen && "absolute right-0 opacity-0",
)}
>
<span className={statusSlotClassNames.statusClassName}>
{/* 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 ? (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
"inline-flex items-baseline gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
<CircleDashedIcon aria-hidden className="size-4 shrink-0 self-center" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
<CircleCheckIcon aria-hidden className="size-4 shrink-0 self-center" />
) : topStatus.icon === "woke" ? (
<AlarmClockIcon aria-hidden className="size-4 shrink-0" />
<AlarmClockIcon aria-hidden className="size-4 shrink-0 self-center" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
Expand All @@ -925,12 +927,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
)}
</span>
{props.settlementSupported || showSnoozeButton ? (
<span
className={cn(
"absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity focus-within:static focus-within:opacity-100 group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "static opacity-100",
)}
>
<span className={statusSlotClassNames.actionsClassName}>
{showSnoozeButton ? (
<SnoozePopoverButton
open={snoozeMenuOpen}
Expand Down
Loading