FN-8492: keep mobile task footer actions on one row

Keep task-detail footer actions compact and readable across mobile widths.

- Prevent mobile footer controls from wrapping while preserving shrink behavior.
- Add ellipsis-safe labels for actions and review controls.
- Cover standard and in-review footer layouts with responsive tests.
- Add a patch changeset for the mobile footer fix.

Files changed:
 .../fn-8492-mobile-task-footer-single-row.md       |  7 ++
 .../dashboard/app/components/TaskDetailModal.css   | 74 ++++++++++++++++-
 .../dashboard/app/components/TaskDetailModal.tsx   |  8 +-
 ...etailModal.responsive-and-dependencies.test.tsx | 94 +++++++++++++++++++++-
 4 files changed, 175 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-8492

Fusion-Task-Lineage: dae7a449-5022-4bfe-a879-880349341594

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-22 16:13:00 -07:00
parent 4413699de0
commit e24fb37c8b
4 changed files with 175 additions and 8 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep task detail footer actions on a single row on mobile.
category: fix
dev: Mobile TaskDetailModal `.modal-actions` nowrap + tokenized compression so Actions/Move/Merge fit without overflow (FN-8492).

View File

@@ -3394,16 +3394,82 @@ Live and Feed Activity expansion overlays the content instead of reserving a too
font-size: 16px;
}
.modal-actions {
flex-wrap: wrap;
gap: var(--space-sm);
/*
FNXC:TaskDetailModalResponsive 2026-07-22-00:00:
FN-8492 requires the task-detail footer to keep Actions, Move, and the
Merge/review control on one row from 320 CSS px through the 768px mobile
breakpoint without horizontal overflow. `nowrap` alone only trades wrapping
for overflow, so scoped tokenized packing, flex shrink guards, and label
ellipsis let every text-bearing footer control yield while upward menus stay
unclipped.
*/
.task-detail-content .modal-actions {
flex-wrap: nowrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-md) calc(var(--space-md) + var(--space-xs) / 2) calc(var(--space-md) + env(safe-area-inset-bottom, 0px));
}
.modal-actions .btn {
.task-detail-content .modal-actions-spacer {
flex: 1 1 0;
min-width: 0;
}
.task-detail-content .detail-actions-dropdown,
.task-detail-content .detail-move-dropdown {
min-width: 0;
flex-shrink: 1;
}
.task-detail-content .detail-move-dropdown {
flex: 1 1 auto;
}
.task-detail-content .detail-move-actions-in-review {
display: flex;
flex: 1 1 auto;
flex-wrap: nowrap;
align-items: center;
min-width: 0;
gap: var(--space-xs);
}
.task-detail-content .detail-move-actions-in-review > div,
.task-detail-content .detail-move-actions-in-review > .btn {
flex: 1 1 0;
min-width: 0;
}
.task-detail-content .modal-actions .btn {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
padding-inline: var(--space-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-detail-content .detail-move-btn {
display: flex;
min-width: 0;
max-width: 100%;
}
.task-detail-content .detail-footer-button-label,
.task-detail-content .detail-move-btn__label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-detail-content .detail-move-btn__arrow,
.task-detail-content .modal-actions .btn > svg {
flex-shrink: 0;
}
/* Mobile dropdown menus — position above action bar, constrained to viewport */
.detail-actions-menu,
.detail-move-menu {

View File

@@ -6334,7 +6334,9 @@ export function TaskDetailContent({
aria-haspopup="menu"
aria-expanded={showActionsMenu}
>
{t("taskDetail.actions.menuBtn", "Actions")}
<span className="detail-footer-button-label">
{t("taskDetail.actions.menuBtn", "Actions")}
</span>
<ChevronDown size={12} />
</button>
{showActionsMenu && (
@@ -6407,7 +6409,9 @@ export function TaskDetailContent({
onClick={reviewAction.onSelect}
disabled={reviewAction.disabled}
>
{reviewAction.label}
<span className="detail-footer-button-label">
{reviewAction.label}
</span>
</button>
)}
</div>

View File

@@ -886,6 +886,96 @@ describe("TaskDetailModal", () => {
expect((container.querySelector(".detail-tabs") as HTMLElement).style.borderBottom).toBe("");
});
it("keeps every mobile task footer control shrinkable on one row from 320 CSS px through 768px", () => {
const css = readDashboardStylesSource();
const mobileBlock = getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".task-detail-content .modal-actions");
const footerBlock = getExactCssRuleBlock(mobileBlock, ".task-detail-content .modal-actions");
const spacerBlock = getExactCssRuleBlock(mobileBlock, ".task-detail-content .modal-actions-spacer");
const inReviewBlock = getExactCssRuleBlock(mobileBlock, ".task-detail-content .detail-move-actions-in-review");
const buttonBlock = getExactCssRuleBlock(mobileBlock, ".task-detail-content .modal-actions .btn");
const labelBlock = getExactCssRuleBlock(
mobileBlock,
".task-detail-content .detail-footer-button-label,\n .task-detail-content .detail-move-btn__label",
);
const dropdownBlock = getExactCssRuleBlock(
mobileBlock,
".task-detail-content .detail-actions-dropdown,\n .task-detail-content .detail-move-dropdown",
);
const expandedChatBlock = getExactCssRuleBlock(css, ".task-detail-content--chat-expanded .modal-actions");
/*
FNXC:TaskDetailModalResponsive 2026-07-22-00:00:
FN-8492's supported mobile fit contract is 320 CSS px through 768px. Keep
these source assertions together: `nowrap` without shrink guards and
text ellipsis would merely turn the original second row into overflow.
*/
expect(footerBlock).toContain("flex-wrap: nowrap;");
expect(footerBlock).not.toContain("flex-wrap: wrap;");
expect(footerBlock).toContain("align-items: center;");
expect(footerBlock).toContain("gap: var(--space-xs);");
expect(spacerBlock).toContain("flex: 1 1 0;");
expect(spacerBlock).toContain("min-width: 0;");
expect(dropdownBlock).toContain("min-width: 0;");
expect(dropdownBlock).toContain("flex-shrink: 1;");
expect(inReviewBlock).toContain("display: flex;");
expect(inReviewBlock).toContain("flex-wrap: nowrap;");
expect(inReviewBlock).toContain("min-width: 0;");
expect(inReviewBlock).toContain("gap: var(--space-xs);");
expect(buttonBlock).toContain("flex: 0 1 auto;");
expect(buttonBlock).toContain("min-width: 0;");
expect(buttonBlock).toContain("padding-inline: var(--space-xs);");
expect(buttonBlock).toContain("white-space: nowrap;");
expect(buttonBlock).toContain("overflow: hidden;");
expect(buttonBlock).toContain("text-overflow: ellipsis;");
expect(labelBlock).toContain("min-width: 0;");
expect(labelBlock).toContain("white-space: nowrap;");
expect(labelBlock).toContain("overflow: hidden;");
expect(labelBlock).toContain("text-overflow: ellipsis;");
expect(expandedChatBlock).toContain("display: none;");
expect(css).toMatch(/\.task-detail-content--planner-chat-expanded \.modal-actions,[\s\S]*?\{\s*display:\s*none;/);
});
it("keeps dense in-review and standard task controls in their shared footer", () => {
const { container, unmount } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-review" as Column })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const inReviewFooter = container.querySelector(".modal-actions");
expect(inReviewFooter).toBeTruthy();
expect(inReviewFooter?.contains(screen.getByRole("button", { name: "Actions" }))).toBe(true);
expect(inReviewFooter?.querySelector(".detail-move-btn")).toBeTruthy();
expect(inReviewFooter?.contains(screen.getByRole("button", { name: "Merge & Close" }))).toBe(true);
unmount();
const standard = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-progress" as Column })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const standardFooter = standard.container.querySelector(".modal-actions");
expect(standardFooter).toBeTruthy();
expect(standardFooter?.contains(screen.getByRole("button", { name: "Actions" }))).toBe(true);
expect(standardFooter?.querySelector(".detail-move-btn")).toBeTruthy();
});
it("modal-actions contains Delete and Pause buttons for non-done tasks (via Actions dropdown)", () => {
render(
<TaskDetailModal
@@ -1678,7 +1768,7 @@ describe("TaskDetailModal", () => {
/>,
);
const button = screen.getByText("Awaiting PR checks") as HTMLButtonElement;
const button = screen.getByText("Awaiting PR checks").closest("button") as HTMLButtonElement;
expect(button.disabled).toBe(true);
expect(screen.queryByText("Merge & Close")).toBeNull();
});
@@ -1697,7 +1787,7 @@ describe("TaskDetailModal", () => {
/>,
);
const button = screen.getByText("Creating PR…") as HTMLButtonElement;
const button = screen.getByText("Creating PR…").closest("button") as HTMLButtonElement;
expect(button.disabled).toBe(true);
expect(screen.queryByText("Merge & Close")).toBeNull();
});