FN-8631: remove task card bottom whitespace

Make progress-bearing task cards use their content height without an unused trailing band.

- Remove the fixed minimum height from the task-card steps toggle.
- Cover trailing-row layout across desktop and mobile task-card variants.
- Add a patch changeset for the visual layout fix.

Files changed:
 .changeset/fn-8631-task-card-bottom-space.md       |   7 ++
 packages/dashboard/app/components/TaskCard.css     |   8 +-
 .../app/components/__tests__/TaskCard.test.tsx     | 140 +++++++++++++++++++++
 3 files changed, 153 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-8631

Fusion-Task-Lineage: 408d359f-66ed-4510-8974-3debbf76860f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-31 14:26:58 -07:00
parent cde02b423d
commit 46d5019e2d
3 changed files with 153 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Remove excess blank space below task-card progress controls.
category: fix
dev: The steps toggle now uses its content height instead of a fixed trailing minimum.

View File

@@ -1350,13 +1350,17 @@ so the icon needs no rule of its own.
line-height: 1.2;
}
/* Steps toggle and list */
/*
FNXC:TaskCardLayout 2026-07-31-20:57:
FN-8631 requires the steps toggle to be content-sized: a fixed minimum height made its unused
vertical space look like a dead band at the bottom of cards with progress. Token padding preserves
the established compact mobile interaction rhythm while the card remains the primary touch target.
*/
.card-steps-toggle {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-top: 6px;
min-height: calc(var(--space-xl) + var(--space-md));
padding: var(--space-xs) 0;
background: none;
border: none;

View File

@@ -8354,6 +8354,146 @@ VERIFIED UNCOVERED rather than assumed: mutating `canEdit` back to the hardcoded
nothing caught it. These four assert the real `aria-label`, and that mutation now fails with
"Unable to find an accessible element ... name 'Edit task'".
*/
/*
FNXC:TaskCardLayout 2026-07-31-20:57:
FN-8631 protects the board-density contract at both supported card breakpoints. jsdom has no layout
engine, so this suite enforces the structural form of the visual invariant: progress toggles are
content-sized and no known trailing row mounts without visible content.
*/
describe("TaskCard trailing-row layout (FN-8631)", () => {
const trailingRowSelectors = [
".card-meta",
".card-agent-row",
".card-action-row",
".card-promote-cost-row",
".card-agent-badge-row",
".card-workflow-badge-row",
];
const originalInnerWidth = window.innerWidth;
const originalMatchMedia = window.matchMedia;
function setCardBreakpoint(width: number) {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("max-width: 768px") ? width <= 768 : false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})) as unknown as typeof window.matchMedia;
}
function expectContentBackedTrailingRows(container: HTMLElement) {
for (const selector of trailingRowSelectors) {
for (const row of Array.from(container.querySelectorAll(selector))) {
expect(row.children.length, `${selector} must not render as an empty trailing shell`).toBeGreaterThan(0);
}
}
}
afterEach(() => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
window.matchMedia = originalMatchMedia;
});
it.each([1280, 390])("keeps every trailing-card variant content-backed at %ipx", (width) => {
setCardBreakpoint(width);
const cleanupCss = mountCssForBadgeTests();
try {
const progressTask = makeTask({
id: `FN-progress-${width}`,
column: "todo",
status: "executing" as any,
steps: [{ name: "Implementation", status: "in-progress" }],
});
const variants = [
{
name: "collapsed progress",
renderCard: () => render(<TaskCard task={progressTask} onOpenDetail={noop} addToast={noop} />),
assert: (container: HTMLElement) => expect(container.querySelector(".card-steps-toggle")).not.toBeNull(),
},
{
name: "no progress, meta, or action row",
renderCard: () => render(<TaskCard task={makeTask({ id: `FN-minimal-${width}`, column: "todo" })} onOpenDetail={noop} addToast={noop} />),
assert: (container: HTMLElement) => {
expect(container.querySelector(".card-steps-toggle")).toBeNull();
expect(container.querySelector(".card-meta")).toBeNull();
expect(container.querySelector(".card-action-row")).toBeNull();
},
},
{
name: "promote cost",
renderCard: () => render(
<CostBadgeProvider value={{ enabled: true }}>
<TaskCard
task={makeTask({
id: `FN-cost-${width}`,
column: "todo",
tokenUsage: { inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000_000, firstUsedAt: "2026-01-01T00:00:00Z", lastUsedAt: "2026-01-01T00:00:00Z", modelProvider: "openai", modelId: "gpt-5-mini" },
} as Partial<Task>)}
onOpenDetail={noop}
addToast={noop}
onPromote={vi.fn()}
/>
</CostBadgeProvider>,
),
assert: (container: HTMLElement) => expect(container.querySelector(".card-promote-cost-row .card-cost-indicator")).not.toBeNull(),
},
{
name: "workflow and agent rows",
renderCard: () => render(
<TaskCard
task={makeTask({ id: `FN-workflow-agent-${width}`, modelProvider: "openai" })}
onOpenDetail={noop}
addToast={noop}
workflowBadge={{ workflowId: "wf-1", workflowName: "Workflow" }}
/>,
),
assert: (container: HTMLElement) => {
expect(container.querySelector(".card-agent-row")).not.toBeNull();
expect(container.querySelector(".card-workflow-badge-row")).not.toBeNull();
},
},
];
for (const variant of variants) {
const view = variant.renderCard();
variant.assert(view.container);
expectContentBackedTrailingRows(view.container);
view.unmount();
}
const expanded = render(<TaskCard task={progressTask} onOpenDetail={noop} addToast={noop} />);
fireEvent.click(expanded.container.querySelector(".card-steps-toggle") as HTMLButtonElement);
expect(expanded.container.querySelector(".card-steps-list")).not.toBeNull();
expectContentBackedTrailingRows(expanded.container);
expanded.unmount();
const editing = render(
<TaskCard task={makeTask({ id: `FN-editing-${width}`, column: "todo" })} onOpenDetail={noop} addToast={noop} onUpdateTask={noop} />,
);
// Editing returns early with only edit content, so none of the normal trailing rows can leave an empty shell.
fireEvent.click(editing.container.querySelector(".card-edit-btn") as HTMLButtonElement);
expect(editing.container.querySelector(".card-editing")).not.toBeNull();
for (const selector of trailingRowSelectors) expect(editing.container.querySelector(selector)).toBeNull();
const css = loadAllAppCss();
const stepsToggleRule = css.match(/\.card-steps-toggle\s*\{[^}]*\}/)?.[0] ?? "";
expect(stepsToggleRule).not.toContain("min-height");
expect(declaredStyle(".card-steps-toggle", "padding")).toBe("var(--space-xs) 0");
if (width <= 768) {
// FN-4351: mobile keeps the existing compact, token-sized toggle rather than adding a fixed minimum.
expect(stepsToggleRule).toContain("padding: var(--space-xs) 0");
}
} finally {
cleanupCss();
}
});
});
describe("TaskCard field editability resolves column traits (U12 — R8)", () => {
const EDIT_LABEL = { name: "Edit task" };