From 2af01dd27d3f45f75f94d3c285e6bf7fdadbe480 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 4 Aug 2026 11:13:07 -0700 Subject: [PATCH] FN-8786: make task titles directly expandable Make overflowed task titles directly expandable with an accessible title control. - Replace the standalone Show more/Show less row with a title-owned button. - Add focus styling while preserving title clamping and summarization behavior. - Cover title expansion, keyboard access, fallbacks, and responsive styling. Files changed: .../dashboard/app/components/TaskDetailModal.css | 35 ++--- .../dashboard/app/components/TaskDetailModal.tsx | 26 ++-- .../__tests__/TaskDetailModal.rendering.test.tsx | 150 +++++++++++---------- 3 files changed, 112 insertions(+), 99 deletions(-) Fusion-Task-Id: FN-8786 Fusion-Task-Lineage: 351a9f33-42c0-4a4f-a34a-94aa726f2436 Co-authored-by: Fusion (runfusion.ai) --- .../app/components/TaskDetailModal.css | 35 ++-- .../app/components/TaskDetailModal.tsx | 26 +-- .../TaskDetailModal.rendering.test.tsx | 150 +++++++++--------- 3 files changed, 112 insertions(+), 99 deletions(-) diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 433dda5846..75eff4dc1c 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -226,6 +226,24 @@ Summarize-as-title is an in-field affordance, not a separate full-width row: it min-width: 0; } +.detail-title-control { + display: block; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: inherit; + cursor: pointer; +} + +.detail-title-control:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); + border-radius: var(--radius-sm); +} + .detail-summarize-title-btn { display: inline-flex; align-items: center; @@ -257,22 +275,6 @@ Summarize-as-title is an in-field affordance, not a separate full-width row: it cursor: not-allowed; } -.detail-description-toggle { - display: block; - background: none; - border: none; - color: var(--text-dim); - font-size: 13px; - padding: 2px 0; - margin-bottom: var(--space-md); - cursor: pointer; - text-align: left; -} - -.detail-description-toggle:hover { - color: var(--text); -} - /* FNXC:TaskDetailMeta 2026-06-13-17:32: The task-detail modal metadata must keep priority, execution mode, provenance, PR context, and timestamps in one horizontal row that wraps as needed instead of stacking each child as a separate line. @@ -1576,7 +1578,6 @@ Planner Chat should not open expanded by default. When the user expands it, the min-height: 0; } -.task-detail-content--planner-chat-expanded .detail-description-toggle, .task-detail-content--planner-chat-expanded .detail-near-duplicate-banner, .task-detail-content--planner-chat-expanded .detail-plan-approval-banner, .task-detail-content--planner-chat-expanded .task-fields-section, diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 9913b2dddc..bcb442ab9e 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -4732,11 +4732,27 @@ export function TaskDetailContent({ Summarize-as-title renders inline with the title inside .detail-heading-row and is positioned (CSS) to the far bottom-right as an in-field affordance, not a separate full-width row. Markup order is preserved; only layout changed. */}
+ {/* + FNXC:TaskDetailTitle 2026-08-04-18:00: + An overflowing task-detail title is its own sole expansion control. Keep the semantic button inside the h2 so pointer, touch, and keyboard activation share one accessible target; the separate Show more/Show less row must not return. + */}

- {displayTitleText} + {titleOverflows || descriptionExpanded ? ( + + ) : displayTitleText}

{showSummarizeTitleButton && (
- {(titleOverflows || descriptionExpanded) && ( - - )} {customFieldDefs && customFieldDefs.length > 0 ? ( { />, ); + const expectNoStandaloneTitleToggle = () => { + expect(document.querySelector(".detail-description-toggle")).toBeNull(); + expect(screen.queryByRole("button", { name: "Show more" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Show less" })).toBeNull(); + }; + beforeEach(() => { setTitleLayout({ scrollHeight: 120, clientHeight: 40 }); Object.defineProperty(HTMLElement.prototype, "scrollHeight", { @@ -2123,9 +2129,13 @@ describe("TaskDetailModal", () => { } }); - it("collapses long triage title by default with Show more button and expands on demand", async () => { + it("toggles a long triage title directly without a standalone affordance", async () => { + /* + FNXC:TaskDetailTitle 2026-08-04-18:00: + Every TaskDetailModal lifecycle column and title fallback shares this definition header. An overflowed title alone owns expansion for pointer, touch, and keyboard users; short and fallback headings retain no empty control or legacy Show more/Show less shell, while Summarize remains separate. + */ const longTitle = "Triage title ".repeat(25); - const { container } = renderDetail({ + renderDetail({ column: "triage", title: longTitle, description: "Triage planning context", @@ -2134,28 +2144,45 @@ describe("TaskDetailModal", () => { const h2 = document.querySelector("h2.detail-title"); expect(h2?.textContent).toBe(longTitle); expect(h2).toHaveClass("detail-title--collapsed"); - const toggle = await screen.findByRole("button", { name: "Show more" }); - expect(toggle).toHaveClass("detail-description-toggle"); + const titleControl = await screen.findByRole("button", { name: "Expand task title" }); + expect(titleControl).toHaveAttribute("aria-expanded", "false"); + expectNoStandaloneTitleToggle(); - await userEvent.click(toggle); + await userEvent.click(titleControl); expect(document.querySelector("h2.detail-title")?.textContent).toBe(longTitle); expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed"); - expect(screen.getByRole("button", { name: "Show less" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Collapse task title" })).toHaveAttribute("aria-expanded", "true"); + expectNoStandaloneTitleToggle(); + + await userEvent.click(screen.getByRole("button", { name: "Collapse task title" })); + + expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed"); + expect(screen.getByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false"); + expectNoStandaloneTitleToggle(); }); - it("collapses long triage description by default when title is missing", async () => { + it("supports keyboard activation through the title control", async () => { + renderDetail({ title: "Keyboard title ".repeat(25) }); + + const titleControl = await screen.findByRole("button", { name: "Expand task title" }); + titleControl.focus(); + await userEvent.keyboard("{Enter}"); + + expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed"); + expect(screen.getByRole("button", { name: "Collapse task title" })).toHaveAttribute("aria-expanded", "true"); + expectNoStandaloneTitleToggle(); + }); + + it("collapses a long triage description fallback by default when title is missing", async () => { const longDescription = "Triage description ".repeat(20); - const { container } = renderDetail({ - column: "triage", - title: undefined, - description: longDescription, - }); + renderDetail({ column: "triage", title: undefined, description: longDescription }); const h2 = document.querySelector("h2.detail-title"); expect(h2?.textContent).toBe(longDescription); expect(h2).toHaveClass("detail-title--collapsed"); - expect(await screen.findByRole("button", { name: "Show more" })).toHaveClass("detail-description-toggle"); + expect(await screen.findByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false"); + expectNoStandaloneTitleToggle(); }); it("uses the title, description, and id fallback chain for the clamped heading", async () => { @@ -2178,39 +2205,40 @@ describe("TaskDetailModal", () => { renderDetail({ title: "Title wins", description: "Description loses" }); expect(document.querySelector("h2.detail-title")?.textContent).toBe("Title wins"); expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed"); - expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "Expand task title" })).toBeInTheDocument(); + expectNoStandaloneTitleToggle(); cleanup(); setTitleLayout({ scrollHeight: 40, clientHeight: 40 }); renderDetail({ title: undefined, description: "Description fallback" }); expect(document.querySelector("h2.detail-title")?.textContent).toBe("Description fallback"); - expect(document.querySelector(".detail-description-toggle")).toBeNull(); + expect(screen.queryByRole("button", { name: "Expand task title" })).toBeNull(); + expectNoStandaloneTitleToggle(); cleanup(); renderDetail({ id: "FN-FALLBACK", title: undefined, description: undefined }); expect(document.querySelector("h2.detail-title")?.textContent).toBe("FN-FALLBACK"); - expect(document.querySelector(".detail-description-toggle")).toBeNull(); + expect(screen.queryByRole("button", { name: "Expand task title" })).toBeNull(); + expectNoStandaloneTitleToggle(); }); it.each(["todo", "in-progress", "in-review", "done", "archived"] as const)( - "collapses overflowing non-triage %s title by default", + "collapses overflowing non-triage %s titles with a title-owned control", async (column) => { const longTitle = `${column} title `.repeat(25); - const { container } = renderDetail({ - column, - title: longTitle, - }); + renderDetail({ column, title: longTitle }); const h2 = document.querySelector("h2.detail-title"); expect(h2?.textContent).toBe(longTitle); expect(h2).toHaveClass("detail-title--collapsed"); - expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false"); + expectNoStandaloneTitleToggle(); }, ); - it("does not render an empty toggle shell when the title fits within two lines", () => { + it("does not render an empty title control when the title fits within two lines", () => { setTitleLayout({ scrollHeight: 40, clientHeight: 40 }); - const { container } = renderDetail({ + renderDetail({ title: "Short title", description: "This is a longer description that is not shown as the heading while title is present", }); @@ -2218,41 +2246,17 @@ describe("TaskDetailModal", () => { const h2 = document.querySelector("h2.detail-title"); expect(h2?.textContent).toBe("Short title"); expect(h2).toHaveClass("detail-title--collapsed"); - expect(document.querySelector(".detail-description-toggle")).toBeNull(); - }); - - it("collapses again when Show less is clicked", async () => { - const longDescription = "C".repeat(250); - const { container } = renderDetail({ - title: undefined, - description: longDescription, - }); - - const toggle = await screen.findByRole("button", { name: "Show more" }); - await userEvent.click(toggle); - expect(document.querySelector("h2.detail-title")?.textContent).toBe(longDescription); - expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed"); - - await userEvent.click(screen.getByRole("button", { name: "Show less" })); - - const h2 = document.querySelector("h2.detail-title"); - expect(h2?.textContent).toBe(longDescription); - expect(h2).toHaveClass("detail-title--collapsed"); - expect(screen.getByRole("button", { name: "Show more" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Expand task title" })).toBeNull(); + expectNoStandaloneTitleToggle(); }); it("resets to collapsed when switching from a non-triage task to a triage task", async () => { const todoDescription = "G".repeat(250); const triageDescription = "H".repeat(250); - const { container, rerender } = render( + const { rerender } = render( { />, ); - await userEvent.click(await screen.findByRole("button", { name: "Show more" })); + await userEvent.click(await screen.findByRole("button", { name: "Expand task title" })); expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed"); rerender( { expect(document.querySelector("h2.detail-title")?.textContent).toBe(triageDescription); }); expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed"); - expect(screen.getByRole("button", { name: "Show more" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false"); + expectNoStandaloneTitleToggle(); }); it("keeps the editing title form unaffected by the read-only clamp", async () => { const longTitle = "Editable title ".repeat(25); - const { container } = renderDetail({ - column: "todo", - title: longTitle, - description: "Editable description", - }); + renderDetail({ column: "todo", title: longTitle, description: "Editable description" }); - expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "Expand task title" })).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Edit task" })); expect(document.querySelector("h2.detail-title")).toBeNull(); - expect(document.querySelector(".detail-description-toggle")).toBeNull(); + expectNoStandaloneTitleToggle(); expect(screen.getByLabelText("Title")).toHaveValue(longTitle); }); - it("keeps the summarize-title affordance aligned next to the clamped title", async () => { - const { container } = renderDetail({ + it("keeps the summarize-title affordance distinct from title expansion", async () => { + renderDetail({ column: "todo", title: "Summarize me ".repeat(25), description: "Description available for summarization", }); expect(document.querySelector(".detail-heading-row h2.detail-title--collapsed")).toBeInTheDocument(); - expect(screen.getByTestId("summarize-title-btn")).toBeInTheDocument(); - expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument(); + const titleControl = await screen.findByRole("button", { name: "Expand task title" }); + const summarizeButton = screen.getByTestId("summarize-title-btn"); + expect(summarizeButton).not.toBe(titleControl); + await userEvent.click(summarizeButton); + expect(titleControl).toHaveAttribute("aria-expanded", "false"); + expectNoStandaloneTitleToggle(); }); it("keeps the clamp available in chat-expanded layout", async () => { - const { container } = render( + render( { expect(document.querySelector(".task-detail-content--chat-expanded")).toBeInTheDocument(); expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed"); - expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "Expand task title" })).toBeInTheDocument(); + expectNoStandaloneTitleToggle(); }); it("has desktop and mobile CSS rules that preserve the two-line title clamp", () => { @@ -2347,8 +2348,11 @@ describe("TaskDetailModal", () => { expect(css).toContain(".detail-title--collapsed"); expectBaseRule(css, ".detail-title--collapsed", "-webkit-line-clamp: 2"); expectBaseRule(css, ".detail-title--collapsed", "line-clamp: 2"); + expectBaseRule(css, ".detail-title-control", "width: 100%"); + expectBaseRule(css, ".detail-title-control:focus-visible", "box-shadow: var(--focus-ring-strong)"); expect(css).toContain("@media (max-width: 768px)"); expectBaseRule(css, ".detail-title", "font-size: 16px"); + expect(css).not.toContain(".detail-description-toggle"); }); });