FN-7933: align mobile task-card header controls to one centerline
Follow-up to FN-7928: fix vertical alignment of the Send-back/Actions trigger, menu button, and size badge in the mobile task-card header actions. - Set line-height: 1 on .card-menu-btn, .card-size-badge, and .card-send-back-btn so their text/icon baselines match instead of drifting from default line-height. - Add align-items: center to .card-header-actions at the mobile breakpoint so Send back, menu, and size badge share one optical vertical centerline. - Add padding-block to .card-size-badge to keep the badge's rendered height consistent with the neighboring controls after the line-height fix. - Add regression tests (TaskCard.badge-wrap.test.tsx) asserting the mobile header-actions rule set (min-height, align-items, gap) and per-control line-height/padding declarations, plus an awaiting-user-input coverage case exercising the send-back/menu/size-badge centerline together. Files changed: packages/dashboard/app/components/TaskCard.css | 16 ++++ .../__tests__/TaskCard.badge-wrap.test.tsx | 92 ++++++++++++++++++++++ 2 files changed, 108 insertions(+) Fusion-Task-Id: FN-7933 Fusion-Task-Lineage: e3d52b36-2446-4cdf-8faf-5c94b0f52786 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -1555,6 +1555,7 @@ short-landscape variant (FN-5751 lesson: never a desktop-only or portrait-only f
|
||||
opacity: 1;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
line-height: 1;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@@ -1822,11 +1823,21 @@ FN-7928 requires the Send-back/Actions trigger, ⋯ menu button, and size badge
|
||||
min-height: var(--card-chip-height-mobile);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskCardLayout 2026-07-13-00:00:
|
||||
FN-7933 follows FN-7928 by keeping the Send-back/Actions trigger, ⋯ menu button, and size badge on one optical vertical center inside the header actions at the mobile breakpoint. Preserve FN-7889's cluster↔id nudge, FN-7862's flex-start header anchor, FN-7837's badge-wrap/size-chip grouping, FN-7928's desktop centering, and FN-4351's no-min-height mobile rule for the send-back/menu buttons.
|
||||
*/
|
||||
.card-header-actions {
|
||||
align-items: center;
|
||||
gap: calc(var(--space-xs) / 2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-menu-btn,
|
||||
.card-size-badge {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* FN-4351/FN-3965: keep secondary actions visible on touch, but compact per WCAG 2.5.8 because the card tap surface is the primary target for opening task detail. */
|
||||
.card-archive-btn,
|
||||
.card-unarchive-btn,
|
||||
@@ -1836,6 +1847,7 @@ FN-7928 requires the Send-back/Actions trigger, ⋯ menu button, and size badge
|
||||
|
||||
.card-send-back-btn {
|
||||
opacity: 1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-promote-action.card-send-back-btn {
|
||||
@@ -1900,6 +1912,10 @@ FN-7928 requires the Send-back/Actions trigger, ⋯ menu button, and size badge
|
||||
padding: calc(var(--space-xs) / 4) calc((var(--space-xs) * 3) / 2);
|
||||
}
|
||||
|
||||
.card-size-badge {
|
||||
padding-block: calc((var(--space-xs) / 4) + var(--btn-border-width));
|
||||
}
|
||||
|
||||
.card-stalled-review-reason {
|
||||
font-size: 0.625rem;
|
||||
-webkit-line-clamp: 3;
|
||||
|
||||
@@ -82,6 +82,54 @@ function expectSharedHeaderBaseline(container: HTMLElement) {
|
||||
expect(actionsStyles.flex).toBe("0 0 auto");
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
expect(blocks.length).toBeGreaterThan(0);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function getCssRuleBodies(section: string, selectorFragment: string): string[] {
|
||||
const bodies: string[] = [];
|
||||
const pattern = /([^{}]+)\{([\s\S]*?)\}/g;
|
||||
|
||||
for (const match of section.matchAll(pattern)) {
|
||||
if (match[1].includes(selectorFragment)) {
|
||||
bodies.push(match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
expect(bodies.length, `Expected CSS rule for ${selectorFragment}`).toBeGreaterThan(0);
|
||||
return bodies;
|
||||
}
|
||||
|
||||
function expectCssRuleToContain(section: string, selectorFragment: string, declaration: string): void {
|
||||
const bodies = getCssRuleBodies(section, selectorFragment);
|
||||
expect(bodies.some((body) => body.includes(declaration)), `${selectorFragment} should include ${declaration}`).toBe(true);
|
||||
}
|
||||
|
||||
function expectCssRuleNotToContain(section: string, selectorFragment: string, declaration: string): void {
|
||||
const bodies = getCssRuleBodies(section, selectorFragment);
|
||||
for (const body of bodies) {
|
||||
expect(body, `${selectorFragment} should not include ${declaration}`).not.toContain(declaration);
|
||||
}
|
||||
}
|
||||
|
||||
function expectHeaderActionsControlCenterline(container: HTMLElement, expected: {
|
||||
sendBack?: boolean;
|
||||
menu?: boolean;
|
||||
@@ -404,21 +452,65 @@ describe("TaskCard badge wrapping (FN-5162)", () => {
|
||||
|
||||
expectSharedHeaderBaseline(sizeAbsentContainer);
|
||||
expectHeaderActionsControlCenterline(sizeAbsentContainer, { sendBack: true, menu: true });
|
||||
|
||||
const { container: awaitingInputContainer } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id: "FN-7933-AWAITING-INPUT",
|
||||
column: "in-progress",
|
||||
status: "awaiting-user-input" as Task["status"],
|
||||
size: "M",
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
onOpenDetailWithTab={noop}
|
||||
addToast={noop}
|
||||
onMoveTask={async () => makeTask()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(awaitingInputContainer.querySelector(".card-answer-questions-btn")).toBeTruthy();
|
||||
expectSharedHeaderBaseline(awaitingInputContainer);
|
||||
expectHeaderActionsControlCenterline(awaitingInputContainer, { sendBack: true, menu: true, size: true });
|
||||
});
|
||||
|
||||
it("keeps the centered-id nudge and mobile header rhythm tokenized with the badge-wrap contract", () => {
|
||||
const cardHeaderRule = loadedCss.match(/\.card-header\s*\{(?<body>[^}]*)\}/)?.groups?.body ?? "";
|
||||
const cardIdRule = loadedCss.match(/\.card-id\s*\{(?<body>[^}]*)\}/)?.groups?.body ?? "";
|
||||
const actionsRule = loadedCss.match(/\.card-header-actions\s*\{(?<body>[^}]*)\}/)?.groups?.body ?? "";
|
||||
expect(cardHeaderRule).toContain("align-items: flex-start;");
|
||||
expect(cardIdRule).toContain("min-height: var(--card-chip-height);");
|
||||
expect(cardIdRule).toContain("line-height: 1;");
|
||||
expect(cardIdRule).toContain("transform: translateY(calc(var(--space-xs) / 4));");
|
||||
expect(cardIdRule).not.toMatch(/translateY\(\d/);
|
||||
expect(actionsRule).toContain("align-items: center;");
|
||||
expect(actionsRule).toContain("transform: translateY(calc(var(--space-xs) / 4));");
|
||||
expect(actionsRule).not.toMatch(/translateY\(\d/);
|
||||
expect(loadedCss).toContain(".card-id,\n .card-header-badges,\n .card-header-actions");
|
||||
expect(loadedCss).toContain("min-height: var(--card-chip-height-mobile);");
|
||||
});
|
||||
|
||||
it("locks the mobile Send back, menu, and size controls to one header-actions centerline", () => {
|
||||
const mobileSection = getCssBlocks(loadedCss, "max-width: 768px").join("\n");
|
||||
const menuTouchSection = getCssBlocks(loadedCss, "max-height: 480px").join("\n");
|
||||
|
||||
expectCssRuleToContain(mobileSection, ".card-header-actions", "min-height: var(--card-chip-height-mobile);");
|
||||
expectCssRuleToContain(mobileSection, ".card-header-actions", "align-items: center;");
|
||||
expectCssRuleToContain(mobileSection, ".card-header-actions", "gap: calc(var(--space-xs) / 2);");
|
||||
expectCssRuleToContain(mobileSection, ".card-send-back-btn", "line-height: 1;");
|
||||
expectCssRuleToContain(mobileSection, ".card-menu-btn", "line-height: 1;");
|
||||
expectCssRuleToContain(mobileSection, ".card-size-badge", "line-height: 1;");
|
||||
expectCssRuleToContain(mobileSection, ".card-size-badge", "font-size: 0.5625rem;");
|
||||
expectCssRuleToContain(mobileSection, ".card-size-badge", "padding: calc(var(--space-xs) / 4) calc((var(--space-xs) * 3) / 2);");
|
||||
expectCssRuleToContain(mobileSection, ".card-size-badge", "padding-block: calc((var(--space-xs) / 4) + var(--btn-border-width));");
|
||||
expectCssRuleNotToContain(mobileSection, ".card-send-back-btn", "min-height:");
|
||||
expectCssRuleNotToContain(mobileSection, ".card-menu-btn", "min-height:");
|
||||
expectCssRuleToContain(menuTouchSection, ".card-menu-btn", "width: 28px;");
|
||||
expectCssRuleToContain(menuTouchSection, ".card-menu-btn", "height: 28px;");
|
||||
expectCssRuleToContain(menuTouchSection, ".card-menu-btn", "line-height: 1;");
|
||||
expectCssRuleToContain(menuTouchSection, ".card-menu-btn svg", "width: 16px;");
|
||||
expectCssRuleToContain(menuTouchSection, ".card-menu-btn svg", "height: 16px;");
|
||||
});
|
||||
|
||||
it.each([
|
||||
".card-status-badge",
|
||||
".card-priority-badge",
|
||||
|
||||
Reference in New Issue
Block a user