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 {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
row-gap: var(--space-xs);
|
row-gap: var(--space-xs);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-bottom: var(--space-xs);
|
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 {
|
.card-meta-badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.card-header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
align-self: flex-start;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: var(--space-xs);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1773,9 +1793,9 @@ too so future divergence doesn't have to rediscover the shared selector group.
|
|||||||
height: 16px;
|
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 {
|
.card-header-actions {
|
||||||
gap: 4px;
|
gap: calc(var(--space-xs) / 2);
|
||||||
flex-shrink: 0;
|
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 {
|
.card-meta-badges {
|
||||||
gap: calc(var(--space-xs) / 2);
|
gap: calc(var(--space-xs) / 2);
|
||||||
row-gap: var(--space-xs);
|
row-gap: var(--space-xs);
|
||||||
|
|||||||
@@ -2757,6 +2757,34 @@ function TaskCardComponent({
|
|||||||
// metadata (absent for the common "off" default) — include it in the wrapper
|
// 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.
|
// guard so `.card-meta-badges` only renders when it has a real child.
|
||||||
|| showOversightBadge;
|
|| 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) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
@@ -2834,6 +2862,12 @@ function TaskCardComponent({
|
|||||||
)}
|
)}
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<span className="card-id">{task.id}</span>
|
<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 && (
|
{isPaused && (
|
||||||
<span
|
<span
|
||||||
className="card-status-badge paused"
|
className="card-status-badge paused"
|
||||||
@@ -3072,6 +3106,9 @@ function TaskCardComponent({
|
|||||||
{abbreviateMissionTitle(missionTitle ?? task.missionId)}
|
{abbreviateMissionTitle(missionTitle ?? task.missionId)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasHeaderActions && (
|
||||||
<div className="card-header-actions">
|
<div className="card-header-actions">
|
||||||
{isAwaitingInput && onOpenDetailWithTab && (
|
{isAwaitingInput && onOpenDetailWithTab && (
|
||||||
<button
|
<button
|
||||||
@@ -3205,6 +3242,7 @@ function TaskCardComponent({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showStalledReview && stalledReview && (
|
{showStalledReview && stalledReview && (
|
||||||
<div className="card-stalled-review-reason" title={stalledReview.reason}>
|
<div className="card-stalled-review-reason" title={stalledReview.reason}>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ vi.mock("lucide-react", () => ({
|
|||||||
Zap: () => null,
|
Zap: () => null,
|
||||||
AlertTriangle: () => null,
|
AlertTriangle: () => null,
|
||||||
Eye: () => null,
|
Eye: () => null,
|
||||||
|
MoreHorizontal: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../ProviderIcon", () => ({
|
vi.mock("../ProviderIcon", () => ({
|
||||||
@@ -98,13 +99,74 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
|
|||||||
cleanupCss = undefined;
|
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 header = container.querySelector(".card-header");
|
||||||
|
const headerBadges = container.querySelector(".card-header-badges");
|
||||||
expect(header).toBeTruthy();
|
expect(header).toBeTruthy();
|
||||||
|
expect(headerBadges).toBeTruthy();
|
||||||
|
|
||||||
const styles = getComputedStyle(header!);
|
const headerStyles = getComputedStyle(header!);
|
||||||
expect(styles.flexWrap).toBe("wrap");
|
expect(headerStyles.flexWrap).toBe("nowrap");
|
||||||
expect(styles.rowGap).toMatch(/^(var\(--space-xs\)|(?!0px$)\d+(?:\.\d+)?px)$/);
|
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([
|
it.each([
|
||||||
|
|||||||
@@ -2029,8 +2029,10 @@ describe("TaskCard", () => {
|
|||||||
const badge = container.querySelector(".card-status-badge")!;
|
const badge = container.querySelector(".card-status-badge")!;
|
||||||
expect(cardId).toBeDefined();
|
expect(cardId).toBeDefined();
|
||||||
expect(badge).toBeDefined();
|
expect(badge).toBeDefined();
|
||||||
// Badge should be the next sibling of card-id
|
const headerBadges = container.querySelector(".card-header-badges")!;
|
||||||
expect(cardId.nextElementSibling).toBe(badge);
|
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", () => {
|
it("does not render a status badge when task.status is falsy", () => {
|
||||||
@@ -3401,18 +3403,29 @@ describe("TaskCard", () => {
|
|||||||
expect(actionsContainer?.contains(sizeBadge)).toBe(true);
|
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(
|
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 cardId = container.querySelector(".card-id")!;
|
||||||
|
const headerBadges = container.querySelector(".card-header-badges")!;
|
||||||
const actionsContainer = container.querySelector(".card-header-actions")!;
|
const actionsContainer = container.querySelector(".card-header-actions")!;
|
||||||
|
|
||||||
expect(cardId).not.toBeNull();
|
expect(cardId).not.toBeNull();
|
||||||
|
expect(headerBadges).not.toBeNull();
|
||||||
expect(actionsContainer).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(
|
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();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user