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:
gsxdsm
2026-07-11 21:24:28 -07:00
parent cc743eec38
commit 66e91f96ae
5 changed files with 157 additions and 16 deletions

View 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).

View File

@@ -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);

View File

@@ -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}>

View File

@@ -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([

View File

@@ -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();
});