FN-8675: align task-card size badges

Align direct size chips with adjacent task-card header badges.

- Center size badges on the shared header row across desktop and responsive layouts.
- Add coverage for size badge alignment, dimensions, and responsive CSS rules.
- Add a patch changeset for the dashboard alignment fix.

Files changed:
 .changeset/fn-8675-size-badge-alignment.md         |  7 +++
 packages/dashboard/app/components/TaskCard.css     |  8 +++
 .../__tests__/TaskCard.badge-height.test.tsx       | 68 ++++++++++++++++++++++
 .../__tests__/TaskCard.badge-wrap.test.tsx         | 10 ++++
 4 files changed, 93 insertions(+)

Fusion-Task-Id: FN-8675

Fusion-Task-Lineage: bf2eefb8-dbc2-40dd-a009-800f883146a8

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 00:10:04 -07:00
parent ec55889605
commit 3b680cce04
4 changed files with 93 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Align task-card size badges with neighboring header badges.
category: fix
dev: Keeps direct size chips in their first header row with token-derived vertical centering.

View File

@@ -548,11 +548,15 @@ FN-6774 removes the saturated stuck-task edge stripe from board cards. Keep the
/*
FNXC:TaskCardLayout 2026-08-01-04:48 (FN-8665):
The size chip must derive its box from the shared header-chip padding, line-height, and border so it renders at the same height as status and metadata chips. As a direct child after .card-id, keep it anchored to the header's first row when the middle badge group wraps.
FNXC:TaskCardLayout 2026-08-01-06:46 (FN-8675):
Every task-card header badge must share the first-row vertical centerline. The direct size chip keeps FN-8665's intrinsic badge geometry, so offset it downward by the token-derived space that centers status and metadata chips inside `.card-header-badges` rather than giving it a divergent fixed row height.
*/
.card-size-badge {
align-self: flex-start;
flex-shrink: 0;
box-sizing: border-box;
transform: translateY(calc((var(--space-xs) * 3) / 4));
letter-spacing: 0.5px;
}
@@ -1689,10 +1693,14 @@ The mobile/short-landscape 28px ⋯ touch target must not expand .card-header-ac
/*
FNXC:TaskCardLayout 2026-08-01-04:48 (FN-8665):
Narrow and short-landscape cards keep the size chip first-row anchored while its shared mobile text-chip rule supplies the same intrinsic height as neighboring badges.
FNXC:TaskCardLayout 2026-08-01-06:46 (FN-8675):
Mobile and short-landscape header badges require the same centered first-row alignment as desktop. Preserve the direct size chip's compact intrinsic geometry and apply the token-derived row-center offset instead of changing its height.
*/
.card-size-badge {
align-self: flex-start;
box-sizing: border-box;
transform: translateY(calc((var(--space-xs) * 3) / 4));
}
.card-menu-btn {

View File

@@ -66,6 +66,27 @@ function mountCss() {
return () => style.remove();
}
function getCssBlocks(css: string, atRuleFragment: string): string[] {
const re = /@media[^{}]*\{/g;
const blocks: string[] = [];
for (const match of css.matchAll(re)) {
if (!match[0].includes(atRuleFragment)) continue;
const start = match.index! + match[0].length;
let depth = 1;
let i = start;
while (i < css.length && depth > 0) {
const ch = css[i];
if (ch === "{") depth++;
else if (ch === "}") depth--;
i++;
}
blocks.push(css.slice(start, i - 1));
}
return blocks;
}
describe("TaskCard badge heights (FN-4369)", () => {
it("keeps triage planning, merging, and priority pills at identical dimensions", () => {
const cleanupCss = mountCss();
@@ -207,4 +228,51 @@ describe("TaskCard badge heights (FN-4369)", () => {
cleanupCss();
});
it.each(["S", "M", "L"] as const)("keeps size %s on the centered header-badge row across responsive sections", (size) => {
const cleanupCss = mountCss();
const { container } = render(
<TaskCard
task={makeTask({
id: `FN-8675-${size}`,
status: "planning" as Task["status"],
size,
priority: "urgent" as Task["priority"],
executionMode: "fast",
missionId: "M-8675",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const sizeBadge = container.querySelector(".card-size-badge") as HTMLElement;
const statusBadge = container.querySelector(".card-status-badge") as HTMLElement;
const headerBadges = container.querySelector(".card-header-badges") as HTMLElement;
expect(sizeBadge).toBeTruthy();
expect(statusBadge).toBeTruthy();
expect(headerBadges).toBeTruthy();
expect(headerBadges.querySelectorAll(".card-status-badge, .card-priority-badge, .card-execution-mode-badge, .card-mission-badge").length).toBeGreaterThan(1);
/*
* FNXC:TaskCardLayout 2026-08-01-06:46 (FN-8675):
* Direct size chips cannot use `align-self: center` because a wrapping middle group would center
* them over the whole header. Keep their intrinsic FN-8665 geometry and require the token-based
* offset that matches the group's first-row centered chip position on desktop, mobile, and short landscape.
*/
const rowCenterOffset = "translateY(calc((var(--space-xs) * 3) / 4))";
expect(getComputedStyle(sizeBadge).transform).toBe(rowCenterOffset);
expect(getComputedStyle(statusBadge).height).toBe(getComputedStyle(sizeBadge).height);
const css = loadAllAppCss();
const baseSizeRule = css.match(/\.card-size-badge\s*\{(?<body>[^}]*)\}/)?.groups?.body ?? "";
expect(baseSizeRule).toContain(`transform: ${rowCenterOffset};`);
for (const breakpoint of ["max-width: 768px", "max-height: 480px"]) {
const sections = getCssBlocks(css, breakpoint);
expect(sections.length).toBeGreaterThan(0);
expect(sections.some((section) => /\.card-size-badge\s*\{[^}]*transform: translateY\(calc\(\(var\(--space-xs\) \* 3\) \/ 4\)\);/.test(section))).toBe(true);
}
cleanupCss();
});
});

View File

@@ -62,6 +62,7 @@ vi.mock("../../hooks/useToast", () => ({
const noop = () => {};
const resolvedChipHeightPattern = /^(var\(--card-chip-height\)|22px)$/;
const centeredIdNudgePattern = /^translateY\(calc\(var\(--space-xs\) \/ 4\)\)$/;
const centeredSizeBadgeOffset = "transform: translateY(calc((var(--space-xs) * 3) / 4));";
function expectSharedHeaderBaseline(container: HTMLElement) {
const header = container.querySelector(".card-header") as HTMLElement;
@@ -196,6 +197,7 @@ function expectSizeBadgeAfterTaskId(container: HTMLElement, expected: boolean) {
expect(sizeStyles.height).toBe("auto");
expect(sizeStyles.minHeight).toBe("auto");
expect(sizeStyles.maxHeight).toBe("none");
expect(sizeStyles.transform).toBe("translateY(calc((var(--space-xs) * 3) / 4))");
expect(sizeBadge!.parentElement).toBe(header);
expect(cardId.nextElementSibling).toBe(sizeBadge);
expect(actions?.contains(sizeBadge)).toBe(false);
@@ -581,14 +583,22 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
const mobileSection = getCssBlocks(loadedCss, "max-width: 768px").join("\n");
const shortLandscapeSection = getCssBlocks(loadedCss, "max-height: 480px").join("\n");
/*
* FNXC:TaskCardLayout 2026-08-01-06:46 (FN-8675):
* The direct size chip retains intrinsic badge dimensions for FN-8665 parity, then uses this
* token-derived offset to occupy the same first-row centerline as chips centered by the group.
* Check every responsive section because jsdom does not evaluate media queries during render.
*/
expect(sizeBadgeRule).toContain("align-self: flex-start;");
expect(sizeBadgeRule).toContain("box-sizing: border-box;");
expect(sizeBadgeRule).toContain(centeredSizeBadgeOffset);
expect(sizeBadgeRule).not.toContain("align-self: center;");
for (const declaration of ["\n height:", "\n min-height:", "\n max-height:"]) {
expectCssRuleNotToContain(loadedCss, ".card-size-badge", declaration);
}
for (const section of [mobileSection, shortLandscapeSection]) {
expectCssRuleToContain(section, ".card-size-badge", "align-self: flex-start;");
expectCssRuleToContain(section, ".card-size-badge", centeredSizeBadgeOffset);
for (const declaration of ["\n height:", "\n min-height:", "\n max-height:"]) {
expectCssRuleNotToContain(section, ".card-size-badge", declaration);
}