FN-7837: fix task card size badge wrapping onto a misaligned second row
Groups TaskCard header badges so the id and right-aligned size/actions cluster never wrap, fixing the S/M/L size chip dropping to its own row when extra badges (fast-mode, priority, oversight, PR/GitHub, etc.) are present. - Introduce hasHeaderBadges/hasHeaderActions guards and wrap the middle badge cluster in a new .card-header-badges container so only .card-header-badges wraps, while .card-id and .card-header-actions stay pinned to the top row - Update TaskCard.css: .card-header becomes a non-wrapping flex row; .card-header-badges takes over the wrapping/flex-grow behavior previously on .card-header; .card-id gets flex-shrink: 0; .card-header-actions gets flex: 0 0 auto and align-self: flex-start; mobile media query gains .card-header-badges alongside .card-meta-badges - Add FNXC:TaskCardLayout comments documenting the FN-7837 layout requirement (non-wrapping header row, wrapping badge cluster) - Extend TaskCard.badge-wrap.test.tsx and TaskCard.test.tsx coverage for the new header grouping/wrap behavior - Add changeset FN-7837-card-size-badge-alignment.md (patch, fix) for @runfusion/fusion Files changed: .changeset/FN-7837-card-size-badge-alignment.md | 7 +++ packages/dashboard/app/components/TaskCard.css | 29 +++++++-- packages/dashboard/app/components/TaskCard.tsx | 38 ++++++++++++ .../__tests__/TaskCard.badge-wrap.test.tsx | 70 ++++++++++++++++++++-- .../app/components/__tests__/TaskCard.test.tsx | 27 ++++++--- 5 files changed, 156 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-7837 Fusion-Task-Lineage: 9f70abcf-3b23-41b3-8642-aa34a561996d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/FN-7837-card-size-badge-alignment.md
Normal file
7
.changeset/FN-7837-card-size-badge-alignment.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Task card size badge (S/M/L) no longer drops onto a misaligned second row on cards with extra badges.
|
||||
category: fix
|
||||
dev: Groups the wrapping header status/meta badges in TaskCard so `.card-id` and the right-aligned `.card-header-actions` (holding `.card-size-badge`) stay on the top row; fixes the fast-mode (`.card-execution-mode-badge`) orphaned-size-chip case (FN-7832 repro).
|
||||
@@ -96,14 +96,32 @@ Mobile long-press opens the Board task context menu, so non-editing card text mu
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--space-xs);
|
||||
row-gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskCardLayout 2026-07-11-00:00:
|
||||
FN-7837 keeps the id row and right-aligned size/actions cluster together while extra header badges wrap inside this middle group, preventing the size chip from orphaning onto a misaligned second row on desktop and mobile.
|
||||
*/
|
||||
.card-header-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
row-gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-id {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-meta-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1408,7 +1426,9 @@ executing. These map 1:1 to the unified progress status so the dot color encodes
|
||||
.card-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -1773,9 +1793,9 @@ too so future divergence doesn't have to rediscover the shared selector group.
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* FN-4365/FN-4351: keep secondary actions compact and inline with the header title row on mobile. */
|
||||
/* FN-4365/FN-4351/FN-7837: keep secondary actions compact, token-spaced, and inline with the non-wrapping header title row on mobile. */
|
||||
.card-header-actions {
|
||||
gap: 4px;
|
||||
gap: calc(var(--space-xs) / 2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1835,6 +1855,7 @@ too so future divergence doesn't have to rediscover the shared selector group.
|
||||
}
|
||||
|
||||
|
||||
.card-header-badges,
|
||||
.card-meta-badges {
|
||||
gap: calc(var(--space-xs) / 2);
|
||||
row-gap: var(--space-xs);
|
||||
|
||||
@@ -2757,6 +2757,34 @@ function TaskCardComponent({
|
||||
// metadata (absent for the common "off" default) — include it in the wrapper
|
||||
// guard so `.card-meta-badges` only renders when it has a real child.
|
||||
|| showOversightBadge;
|
||||
const hasHeaderBadges = Boolean(isPaused)
|
||||
|| Boolean(!isPaused && visualStatus && visualStatus !== "queued")
|
||||
|| planReviewRunning
|
||||
|| Boolean(!isPaused && task.column === "todo" && !visualStatus && (task.steps?.length ?? 0) > 0)
|
||||
|| Boolean(hasInReviewStall && stallCopy)
|
||||
|| cliWaitingOnInput
|
||||
|| cliNeedsAttention
|
||||
|| Boolean(hasStalePausedReview && stalePausedReviewCopy)
|
||||
|| Boolean(hasTaskAgeStaleness && taskAgeStalenessCopy)
|
||||
|| Boolean(isStuck && (isPaused || !task.status || task.status === "queued"))
|
||||
|| Boolean(Array.isArray((task as TaskWithBranchProgress).branchProgress) && (task as TaskWithBranchProgress).branchProgress!.length > 0)
|
||||
|| Boolean(task.plannerOverseerState && task.plannerOverseerState.state !== "idle")
|
||||
|| Boolean(showStalledReview && stalledReview)
|
||||
|| Boolean(livePrInfo || liveIssueInfo)
|
||||
|| Boolean(task.gitlabTracking?.item)
|
||||
|| Boolean(prNode)
|
||||
|| hasCardMetaBadges
|
||||
|| task.noCommitsExpected === true
|
||||
|| Boolean(task.missionId);
|
||||
const hasHeaderActions = Boolean(isAwaitingInput && onOpenDetailWithTab)
|
||||
|| Boolean(canEdit)
|
||||
|| Boolean(task.column === "triage" && onDeleteTask)
|
||||
|| Boolean(task.column === "done" && onArchiveTask)
|
||||
|| Boolean(task.column === "archived" && onUnarchiveTask)
|
||||
|| Boolean((task.column === "done" || task.column === "archived") && onRevertTask && isRevertable)
|
||||
|| Boolean(task.column === "in-progress" && onMoveTask)
|
||||
|| Boolean(task.size)
|
||||
|| hasContextMenuActions;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
@@ -2834,6 +2862,12 @@ function TaskCardComponent({
|
||||
)}
|
||||
<div className="card-header">
|
||||
<span className="card-id">{task.id}</span>
|
||||
{hasHeaderBadges && (
|
||||
/*
|
||||
FNXC:TaskCardLayout 2026-07-11-00:00:
|
||||
FN-7837 keeps the task id and right-aligned size/actions cluster in the same non-wrapping header row. Extra header badges (fast-mode, priority, oversight, decision-only, PR/GitHub, and status chips) wrap inside this middle group instead of pushing the size chip onto a misaligned second row on desktop or mobile.
|
||||
*/
|
||||
<div className="card-header-badges" data-testid="card-header-badges">
|
||||
{isPaused && (
|
||||
<span
|
||||
className="card-status-badge paused"
|
||||
@@ -3072,6 +3106,9 @@ function TaskCardComponent({
|
||||
{abbreviateMissionTitle(missionTitle ?? task.missionId)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasHeaderActions && (
|
||||
<div className="card-header-actions">
|
||||
{isAwaitingInput && onOpenDetailWithTab && (
|
||||
<button
|
||||
@@ -3205,6 +3242,7 @@ function TaskCardComponent({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showStalledReview && stalledReview && (
|
||||
<div className="card-stalled-review-reason" title={stalledReview.reason}>
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("lucide-react", () => ({
|
||||
Zap: () => null,
|
||||
AlertTriangle: () => null,
|
||||
Eye: () => null,
|
||||
MoreHorizontal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../ProviderIcon", () => ({
|
||||
@@ -98,13 +99,74 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
|
||||
cleanupCss = undefined;
|
||||
});
|
||||
|
||||
it("wraps the header row and keeps a non-zero row gap", () => {
|
||||
it("keeps the outer header row non-wrapping while badges wrap inside their own group", () => {
|
||||
const header = container.querySelector(".card-header");
|
||||
const headerBadges = container.querySelector(".card-header-badges");
|
||||
expect(header).toBeTruthy();
|
||||
expect(headerBadges).toBeTruthy();
|
||||
|
||||
const styles = getComputedStyle(header!);
|
||||
expect(styles.flexWrap).toBe("wrap");
|
||||
expect(styles.rowGap).toMatch(/^(var\(--space-xs\)|(?!0px$)\d+(?:\.\d+)?px)$/);
|
||||
const headerStyles = getComputedStyle(header!);
|
||||
expect(headerStyles.flexWrap).toBe("nowrap");
|
||||
expect(headerStyles.rowGap).toMatch(/^(var\(--space-xs\)|(?!0px$)\d+(?:\.\d+)?px)$/);
|
||||
|
||||
const badgeStyles = getComputedStyle(headerBadges!);
|
||||
expect(badgeStyles.display).toBe("flex");
|
||||
expect(badgeStyles.flexWrap).toBe("wrap");
|
||||
expect(badgeStyles.minWidth).toBe("0px");
|
||||
expect(header?.contains(headerBadges)).toBe(true);
|
||||
expect(container.querySelector(".card-header-actions")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a fast-mode size badge in the right-aligned header actions instead of an orphaned wrapped row", () => {
|
||||
const { container: sizedContainer } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id: "FN-7832",
|
||||
column: "done",
|
||||
status: "done" as Task["status"],
|
||||
size: "S",
|
||||
priority: "urgent" as Task["priority"],
|
||||
executionMode: "fast",
|
||||
noCommitsExpected: true,
|
||||
issueInfo: {
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
number: 7832,
|
||||
state: "open",
|
||||
title: "Fast-mode done card with extra header badges",
|
||||
url: "https://github.com/owner/repo/issues/7832",
|
||||
} as Task["issueInfo"],
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onArchiveTask={async () => makeTask()}
|
||||
workflowBadge={{ workflowId: "wf-fast-size", workflowName: "Fast size workflow" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const header = sizedContainer.querySelector(".card-header") as HTMLElement;
|
||||
const headerBadges = sizedContainer.querySelector(".card-header-badges") as HTMLElement;
|
||||
const actions = sizedContainer.querySelector(".card-header-actions") as HTMLElement;
|
||||
const sizeBadge = sizedContainer.querySelector(".card-size-badge") as HTMLElement;
|
||||
const fastBadge = sizedContainer.querySelector(".card-execution-mode-badge") as HTMLElement;
|
||||
|
||||
expect(header).toBeTruthy();
|
||||
expect(headerBadges).toBeTruthy();
|
||||
expect(actions).toBeTruthy();
|
||||
expect(sizeBadge).toBeTruthy();
|
||||
expect(fastBadge).toBeTruthy();
|
||||
expect(actions.contains(sizeBadge)).toBe(true);
|
||||
expect(headerBadges.contains(fastBadge)).toBe(true);
|
||||
expect(sizeBadge.closest(".card-header-badges")).toBeNull();
|
||||
expect(actions.parentElement).toBe(header);
|
||||
expect(headerBadges.parentElement).toBe(header);
|
||||
|
||||
const headerStyles = getComputedStyle(header);
|
||||
const actionsStyles = getComputedStyle(actions);
|
||||
expect(headerStyles.flexWrap).toBe("nowrap");
|
||||
expect(actionsStyles.marginLeft).toBe("auto");
|
||||
expect(actionsStyles.flexShrink).toBe("0");
|
||||
expect(actionsStyles.alignSelf).toBe("flex-start");
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -2029,8 +2029,10 @@ describe("TaskCard", () => {
|
||||
const badge = container.querySelector(".card-status-badge")!;
|
||||
expect(cardId).toBeDefined();
|
||||
expect(badge).toBeDefined();
|
||||
// Badge should be the next sibling of card-id
|
||||
expect(cardId.nextElementSibling).toBe(badge);
|
||||
const headerBadges = container.querySelector(".card-header-badges")!;
|
||||
expect(headerBadges).toBeDefined();
|
||||
expect(cardId.nextElementSibling).toBe(headerBadges);
|
||||
expect(headerBadges.contains(badge)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not render a status badge when task.status is falsy", () => {
|
||||
@@ -3401,18 +3403,29 @@ describe("TaskCard", () => {
|
||||
expect(actionsContainer?.contains(sizeBadge)).toBe(true);
|
||||
});
|
||||
|
||||
it("places card-header-actions after card-id in DOM order", () => {
|
||||
it("places card-header-actions as a direct header child after the wrapped badge group", () => {
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask({ size: "S" })} onOpenDetail={noop} addToast={noop} />,
|
||||
<TaskCard
|
||||
task={makeTask({ size: "S", priority: "urgent" as Task["priority"], executionMode: "fast" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
const header = container.querySelector(".card-header")!;
|
||||
const cardId = container.querySelector(".card-id")!;
|
||||
const headerBadges = container.querySelector(".card-header-badges")!;
|
||||
const actionsContainer = container.querySelector(".card-header-actions")!;
|
||||
|
||||
|
||||
expect(cardId).not.toBeNull();
|
||||
expect(headerBadges).not.toBeNull();
|
||||
expect(actionsContainer).not.toBeNull();
|
||||
// The actions container should come after card-id
|
||||
expect(actionsContainer.parentElement).toBe(header);
|
||||
expect(headerBadges.parentElement).toBe(header);
|
||||
expect(
|
||||
cardId.compareDocumentPosition(actionsContainer) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
cardId.compareDocumentPosition(headerBadges) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
headerBadges.compareDocumentPosition(actionsContainer) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user