FN-8806: stabilize task title expansion during resize

Keep expanded task titles stable while their container resizes.

- Measure persistent title text separately from the interactive expansion control.
- Fence hidden pop-out resize observers and preserve expansion state across detail hosts.
- Add rendering and browser coverage for resize-driven title behavior.

Files changed:
 .changeset/fn-8806-task-title-flicker.md           |   7 +
 .../dashboard/app/components/TaskDetailModal.css   |  16 +-
 .../dashboard/app/components/TaskDetailModal.tsx   |  37 +--
 .../__tests__/TaskDetailModal.rendering.test.tsx   |  42 +++-
 .../app/task-modal-touch-resize-e2e-fixture.tsx    | 149 +++++++++++-
 .../task-modal-touch-resize-browser.test.ts        | 267 ++++++++++++++++++++-
 6 files changed, 485 insertions(+), 33 deletions(-)

Fusion-Task-Id: FN-8806

Fusion-Task-Lineage: 9519a5dc-ae6d-43e1-a0da-7ac963f27d7f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-05 13:02:48 -07:00
parent 09e4808d7e
commit 6ccde2b0d8
6 changed files with 485 additions and 33 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep expanded task titles stable without flickering during resize.
category: fix
dev: Measures the persistent title text box so its control cannot alter overflow eligibility.

View File

@@ -205,9 +205,19 @@ the previous direct-child flex contract so the transcript and agent log still fi
margin-bottom: var(--space-md);
overflow-wrap: anywhere;
word-break: break-word;
position: relative;
}
.detail-title--collapsed {
/*
FNXC:TaskDetailTitle 2026-08-05-17:54:
The measured text stays in flow while the interactive overlay changes state, so eligibility has
one geometry in every task-detail host instead of depending on whether its button is present.
*/
.detail-title-measurement {
display: block;
}
.detail-title--collapsed .detail-title-measurement {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
@@ -236,10 +246,12 @@ Summarize-as-title is an in-field affordance, not a separate full-width row: it
.detail-title-control {
display: block;
width: 100%;
position: absolute;
inset: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
color: transparent;
font: inherit;
text-align: inherit;
cursor: pointer;

View File

@@ -1207,17 +1207,18 @@ export function TaskDetailContent({
const [highlightStallCode, setHighlightStallCode] = useState<string | null>(null);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [titleOverflows, setTitleOverflows] = useState(false);
const titleRef = useRef<HTMLHeadingElement | null>(null);
const titleRef = useRef<HTMLSpanElement | null>(null);
const displayTitleText = task.title || task.description || task.id;
/*
FNXC:TaskDetailTitle 2026-08-05-16:42:
Title overflow eligibility belongs to the stable two-line collapsed layout, while expanded or
collapsed display is an explicit operator choice. Do not mutate the live expanded heading to
measure it: ResizeObserver callbacks caused by that class change can otherwise replace the
title-owned control and flicker the detail view. Reset both values before paint only when this
task identity or its displayed title/fallback changes; ordinary rerenders and resize callbacks
must never reverse the choice.
FNXC:TaskDetailTitle 2026-08-05-18:48:
Browser layout proved that swapping bare heading text for the semantic title button can alter the
exact box whose overflow decides whether that button exists. Measure an always-present text span
with the collapsed two-line rules instead; the button is an out-of-flow accessible overlay and
cannot feed back into eligibility. Expansion remains an operator-owned state across modal,
full-panel, split-pane, right-dock, and floating-window hosts. Reset only for a new task or its
title/description/id fallback, never from a resize delivery. Kept-alive hidden pop-outs are not a
live layout authority: disconnect and fence their callbacks until their host is visible again.
*/
useLayoutEffect(() => {
setDescriptionExpanded(false);
@@ -1281,6 +1282,10 @@ export function TaskDetailContent({
const [prCreateOpen, setPrCreateOpen] = useState(false);
useLayoutEffect(() => {
// A kept-alive floating detail can be hidden while another view owns its layout. Its stale
// ResizeObserver delivery must not change eligibility before the host becomes visible again.
if (!active) return;
const titleElement = titleRef.current;
if (!titleElement) {
setTitleOverflows(false);
@@ -1311,7 +1316,7 @@ export function TaskDetailContent({
resizeObserver?.disconnect();
window.removeEventListener("resize", measureTitleOverflow);
};
}, [descriptionExpanded, displayTitleText, task.id]);
}, [active, descriptionExpanded, displayTitleText, task.id]);
/*
FNXC:WorkflowBadges 2026-06-29-00:00:
@@ -4797,10 +4802,10 @@ export function TaskDetailContent({
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.
*/}
<h2
ref={titleRef}
className={`detail-title${descriptionExpanded ? "" : " detail-title--collapsed"}`}
>
<h2 className={`detail-title${descriptionExpanded ? "" : " detail-title--collapsed"}`}>
<span ref={titleRef} className="detail-title-measurement">
{displayTitleText}
</span>
{titleOverflows || descriptionExpanded ? (
<button
type="button"
@@ -4810,10 +4815,8 @@ export function TaskDetailContent({
? t("taskDetail.title.collapse", "Collapse task title")
: t("taskDetail.title.expand", "Expand task title")}
onClick={() => setDescriptionExpanded((expanded) => !expanded)}
>
{displayTitleText}
</button>
) : displayTitleText}
/>
) : null}
</h2>
{showSummarizeTitleButton && (
<button

View File

@@ -2334,13 +2334,13 @@ describe("TaskDetailModal", () => {
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
configurable: true,
get() {
return this instanceof HTMLElement && this.classList.contains("detail-title") ? titleScrollHeight : 0;
return this instanceof HTMLElement && this.classList.contains("detail-title-measurement") ? titleScrollHeight : 0;
},
});
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
configurable: true,
get() {
return this instanceof HTMLElement && this.classList.contains("detail-title") ? titleClientHeight : 0;
return this instanceof HTMLElement && this.classList.contains("detail-title-measurement") ? titleClientHeight : 0;
},
});
});
@@ -2401,13 +2401,16 @@ describe("TaskDetailModal", () => {
renderDetail({ title: longTitle });
const titleControl = await screen.findByRole("button", { name: "Expand task title" });
const measuredText = document.querySelector(".detail-title-measurement");
const collapsedObserver = titleResizeObservers.at(-1);
expect(collapsedObserver).toBeDefined();
expect(measuredText?.textContent).toBe(longTitle);
await userEvent.click(titleControl);
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Collapse task title" })).toBe(titleControl);
expect(titleControl).toHaveAttribute("aria-expanded", "true");
expect(document.querySelector(".detail-title-measurement")).toBe(measuredText);
expect(document.querySelector("h2.detail-title")?.textContent).toBe(longTitle);
expect(collapsedObserver?.disconnected).toBe(true);
@@ -2424,6 +2427,7 @@ describe("TaskDetailModal", () => {
await userEvent.click(titleControl);
const recollapsedObserver = titleResizeObservers.at(-1);
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
expect(document.querySelector(".detail-title-measurement")).toBe(measuredText);
expect(screen.getByRole("button", { name: "Expand task title" })).toBe(titleControl);
expect(titleControl).toHaveAttribute("aria-expanded", "false");
expect(recollapsedObserver).not.toBe(collapsedObserver);
@@ -2483,6 +2487,36 @@ describe("TaskDetailModal", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
});
it("ignores title observer deliveries while a kept-alive pop-out is hidden", async () => {
setTitleLayout({ scrollHeight: 40, clientHeight: 40 });
const props = {
embedded: true,
active: true,
initialTab: "definition" as const,
onMoveTask: noopMove,
onDeleteTask: noopDelete,
onMergeTask: noopMerge,
onOpenDetail: noopOpenDetail,
addToast: noop,
};
const { rerender } = render(<TaskDetailContent {...props} task={makeTask({ id: "FN-HIDDEN", title: "Visibility fenced title" })} />);
const visibleObserver = titleResizeObservers.at(-1);
expect(screen.queryByRole("button", { name: /task title/ })).toBeNull();
rerender(<TaskDetailContent {...props} active={false} task={makeTask({ id: "FN-HIDDEN", title: "Visibility fenced title" })} />);
expect(visibleObserver?.disconnected).toBe(true);
setTitleLayout({ scrollHeight: 120, clientHeight: 40 });
await act(async () => {
visibleObserver?.callback([], {} as ResizeObserver);
visibleObserver?.callback([], {} as ResizeObserver);
});
expect(screen.queryByRole("button", { name: /task title/ })).toBeNull();
rerender(<TaskDetailContent {...props} active task={makeTask({ id: "FN-HIDDEN", title: "Visibility fenced title" })} />);
expect(await screen.findByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
});
it("supports keyboard activation through the title control", async () => {
renderDetail({ title: "Keyboard title ".repeat(25) });
@@ -2667,8 +2701,8 @@ describe("TaskDetailModal", () => {
it("has desktop and mobile CSS rules that preserve the two-line title clamp", () => {
const css = readDashboardStylesSource();
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--collapsed .detail-title-measurement", "-webkit-line-clamp: 2");
expectBaseRule(css, ".detail-title--collapsed .detail-title-measurement", "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)");

View File

@@ -1,4 +1,5 @@
import React, { useState } from "react";
import type { Task } from "@fusion/core";
import { createRoot } from "react-dom/client";
import i18n from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
@@ -6,6 +7,13 @@ import "./styles.css";
import "./components/TaskDetailModal.css";
import "./components/FloatingWindow.css";
import { FloatingWindow } from "./components/FloatingWindow";
import { App } from "./App";
import { TaskDetailContent } from "./components/TaskDetailModal";
import { AppModals } from "./components/AppModals";
import { MainContent } from "./components/dashboard/MainContent";
import { ListView } from "./components/ListView";
import { useRightDockController } from "./components/useRightDockController";
import { NavigationHistoryProvider } from "./hooks/useNavigationHistory";
import { NewTaskModal } from "./components/NewTaskModal";
import { AgentListModal } from "./components/AgentListModal";
import { SetupWizardModal } from "./components/SetupWizardModal";
@@ -13,6 +21,7 @@ import { ConfirmDialogProvider } from "./hooks/useConfirm";
const params = new URLSearchParams(window.location.search);
const surface = params.get("surface") ?? "new-task";
const titleMode = params.get("titleMode") ?? "overflow";
if (params.has("reset")) localStorage.clear();
/*
@@ -44,15 +53,141 @@ Their minimal API payloads keep first render deterministic so the CDP assertions
geometry before each committed screenshot is captured.
*/
window.fetch = async (input) => {
const url = String(input);
const payload = url.includes("/models")
? { models: [], favoriteProviders: [], favoriteModels: [] }
: url.includes("/settings") ? {}
: url.includes("/agents") || url.includes("/nodes") ? []
: [];
const url = input instanceof Request ? input.url : String(input);
const pathname = new URL(url, window.location.href).pathname;
const payload = url.includes("/projects/across-nodes")
? [{ id: "fixture", name: "Fixture", path: "/fixture", status: "active" }]
: url.includes("/tasks/board-workflows")
? { flagEnabled: true, defaultWorkflowId: "fixture-workflow", taskWorkflowIds: { [fixtureTask.id]: "fixture-workflow" }, workflows: [{ id: "fixture-workflow", name: "Fixture", columns: [{ id: "todo", name: "Todo", flags: {} }] }] }
: url.includes(`/tasks/${fixtureTask.id}/prompt`)
? { id: fixtureTask.id, prompt: "" }
: pathname === `/api/tasks/${fixtureTask.id}`
? fixtureTask
: pathname === "/api/tasks"
? [fixtureTask]
: url.includes("/insights")
? { insights: [], count: 0 }
: url.includes("/evals")
? { results: [] }
: url.includes("/missions")
? []
: url.includes("/goals")
? { goals: [] }
: url.includes("/models")
? { models: [], favoriteProviders: [], favoriteModels: [] }
: url.includes("/settings") ? { taskPopupsBoardListOnly: false }
: url.includes("/agents") || url.includes("/nodes") ? []
: [];
return new Response(JSON.stringify(payload), { headers: { "content-type": "application/json" } });
};
const fixtureTitle = titleMode === "fit"
? "Fitting browser title"
: titleMode === "threshold"
? "A threshold title whose real host width crosses the two line clamp"
: titleMode === "description" ? undefined : titleMode === "id" ? "" : "A browser measured task title that crosses the two line clamp threshold without changing the operator selected expanded state ".repeat(3);
const fixtureDescription = titleMode === "description" ? "A browser measured description fallback that crosses the two line clamp threshold without changing the operator selected expanded state ".repeat(3) : titleMode === "id" ? "" : "Fixture description";
const fixtureTask = {
id: titleMode === "id" ? "FN-8806" : "FN-TITLE-FLICKER",
title: fixtureTitle,
description: fixtureDescription,
column: "todo",
status: "pending",
prompt: "",
steps: [],
attachments: [],
dependencies: [],
createdAt: "2026-08-05T00:00:00.000Z",
updatedAt: "2026-08-05T00:00:00.000Z",
} as unknown as Task;
/*
FNXC:TaskDetailTitle 2026-08-05-18:48:
The App pop-out browser route hydrates the same project and task caches used after a discarded
session, so its board card exists on App's first render. This removes timing retries from the
fixture while App still revalidates the stable mocked API data through its production hooks.
*/
if (surface === "task-detail-title-app-floating") {
const savedAt = Date.now();
localStorage.setItem("kb-dashboard-projects-cache", JSON.stringify({ savedAt, data: [{ id: "fixture", name: "Fixture", path: "/fixture", status: "active" }] }));
localStorage.setItem("kb-dashboard-current-project-cache", JSON.stringify({ savedAt, data: "fixture" }));
localStorage.setItem("kb-dashboard-tasks-cache:fixture", JSON.stringify({ savedAt, data: [fixtureTask] }));
}
const detailProps = {
task: fixtureTask,
initialTab: "definition" as const,
onMoveTask: async () => fixtureTask,
onDeleteTask: async () => fixtureTask,
onMergeTask: async () => ({ success: true } as never),
onOpenDetail: () => undefined,
addToast: () => undefined,
};
/*
FNXC:TaskDetailTitle 2026-08-05-17:54:
The Chromium fixture renders the production TaskDetailModal and embedded TaskDetailContent paths,
not a title lookalike, so browser geometry can expose control-driven clamp feedback that jsdom does
not calculate. The adapters keep API data inert while preserving the real heading, observer, and
accessible control contract.
*/
const noop = () => undefined;
const asyncTask = async () => fixtureTask;
const asyncMerge = async () => ({ success: true } as never);
/*
FNXC:TaskDetailTitle 2026-08-05-19:01:
The browser regression must enter each production owner rather than wrapping TaskDetailContent in
fixture-only geometry. These minimal adapters provide inert dependencies but retain AppModals,
MainContent, ListView, the right-dock controller, and the task FloatingWindow render paths.
*/
function TaskDetailTitleModalHarness() {
const modalManager = {
detailTask: fixtureTask,
detailTaskOrigin: "board",
closeDetailTask: noop,
updateDetailTask: noop,
openNewTaskWithDescription: noop,
openWorkflowEditor: noop,
};
return <div data-testid="title-host-modal"><NavigationHistoryProvider value={{ pushNav: noop, replaceCurrent: noop, removeNav: noop }}><AppModals projectId="fixture" tasks={[fixtureTask]} projects={[]} currentProject={null} addToast={noop} toasts={[]} removeToast={noop} modalManager={modalManager as never} projectActions={{} as never} taskHandlers={{} as never} taskOperations={{ moveTask: asyncTask, deleteTask: asyncTask, mergeTask: asyncMerge, archiveTask: asyncTask, retryTask: asyncTask, pauseTask: asyncTask, unpauseTask: asyncTask, resetTask: asyncTask, duplicateTask: asyncTask }} deepLink={{ handleDetailClose: noop }} settings={{ prAuthAvailable: false, autoMerge: true, taskDetailChatFirst: false, themeMode: "system", colorTheme: "default", dashboardFontScalePct: 100, shadcnCustomColors: {}, resolvedThemeMode: "light", setThemeMode: noop, setColorTheme: noop, setDashboardFontScalePct: noop, setShadcnCustomColors: noop, setQuickChatButtonModeImmediate: noop, setMobileNavPrimaryItemsImmediate: noop }} /></NavigationHistoryProvider></div>;
}
function TaskDetailTitleMainPanelHarness() {
return <div data-testid="title-host-main-panel" className="fn-8806-constrained-title-host"><MainContent {...{ taskView: "task-detail", mainPanelDetailTask: fixtureTask, tasks: [fixtureTask], currentProject: null, addToast: noop, moveTask: asyncTask, deleteTask: asyncTask, mergeTask: asyncMerge, retryTask: asyncTask, pauseTask: asyncTask, unpauseTask: asyncTask, resetTask: asyncTask, duplicateTask: asyncTask, closeTaskDetailMainPanel: noop, setMainPanelDetailTask: noop, openTaskDetailInMainPanel: noop, popOutTaskDetail: noop, modalManager: { openNewTaskWithDescription: noop }, globalPaused: false, prAuthAvailable: false, autoMerge: true, taskDetailChatFirst: false } as unknown as React.ComponentProps<typeof MainContent>} /></div>;
}
function TaskDetailTitleListHarness() {
localStorage.setItem("kb:fixture:kb-dashboard-list-selected-task", fixtureTask.id);
return <div data-testid="title-host-list"><ListView {...{ tasks: [fixtureTask], projectId: "fixture", onMoveTask: asyncTask, onDeleteTask: asyncTask, onMergeTask: asyncMerge, addToast: noop, onOpenDetail: noop, onNewTask: noop, onQuickCreate: noop, availableModels: [], autoMerge: true, columnFlagsByTaskId: new Map() } as unknown as React.ComponentProps<typeof ListView>} /></div>;
}
function TaskDetailTitleDockHarness() {
localStorage.setItem("fusion:right-dock-open", "true");
localStorage.setItem("fusion:right-dock-view", "tasks");
const dock = useRightDockController({ active: true, projectId: "fixture", tasks: [fixtureTask], addToast: noop, settingsLoaded: true, researchReadinessVersion: 0, workflowSteps: [], subscribePluginEvents: () => noop, openDetailTask: noop, openTaskPopup: noop, openMobileTasksInPopup: false, openFileInBrowser: noop, onMoveTask: asyncTask, onDeleteTask: asyncTask, onMergeTask: asyncMerge, openSettings: noop, onSendSelectionToTask: noop, onCreateTaskFromInsight: noop, onNavigateToMission: noop, onTaskCreated: noop, prAuthAvailable: false, autoMerge: true, taskDetailChatFirst: false, visibilityOptions: {}, footerVisible: false });
React.useEffect(() => { dock.openTaskInDock(fixtureTask); }, []);
return <div data-testid="title-host-dock" className="fn-8806-constrained-title-host">{dock.dock}</div>;
}
function TaskDetailTitleFloatingHarness() {
return <div data-testid="title-host-floating"><FloatingWindow windowKey="fn-8806-task-floating" title={fixtureTask.id} onClose={noop} hideHeader dragHandleSelector=".task-detail-content--embedded > .modal-header" className="floating-window--task-detail" defaultSize={{ width: 560, height: 480 }} minSize={{ width: 320, height: 240 }} layer="task-detail"><TaskDetailContent {...detailProps} embedded onRequestClose={noop} /></FloatingWindow></div>;
}
/*
FNXC:TaskDetailTitle 2026-08-05-18:34:
The floating-title browser route renders App itself, then opens the task through its board-detail
Pop out control. This retains App's live-row selection, origin-view identity, visibility gate, and
usePoppedOutTasks persistence instead of treating a hand-composed FloatingWindow as host coverage.
*/
function TaskDetailTitleAppFloatingHarness() {
return <App />;
}
function TaskDetailTitleEmbeddedHarness() {
return <div data-testid="title-host-embedded" className="fn-8806-constrained-title-host" style={{ width: "24rem", height: "36rem" }}><TaskDetailContent {...detailProps} embedded /></div>;
}
function TaskDetailResizeHarness() {
return <FloatingWindow
windowKey="task-detail-fixture"
@@ -141,7 +276,7 @@ function GenericFloatingWindowHarness() {
function Fixture() {
return <I18nextProvider i18n={i18n}>
<ConfirmDialogProvider skipConfirmations>
{surface === "agent-list-modal" ? <AgentListModal isOpen onClose={() => undefined} addToast={() => undefined} /> : surface === "setup-wizard-modal" ? <SetupWizardModal onProjectRegistered={() => undefined} onClose={() => undefined} /> : surface === "floating-window" ? <FloatingWindowHarness /> : surface === "floating-window-headerless" ? <HeaderlessFloatingWindowHarness /> : surface === "floating-window-generic" ? <GenericFloatingWindowHarness /> : surface === "task-detail" ? <TaskDetailResizeHarness /> : <NewTaskModal
{surface === "task-detail-title-app-floating" ? <TaskDetailTitleAppFloatingHarness /> : surface === "agent-list-modal" ? <AgentListModal isOpen onClose={() => undefined} addToast={() => undefined} /> : surface === "setup-wizard-modal" ? <SetupWizardModal onProjectRegistered={() => undefined} onClose={() => undefined} /> : surface === "floating-window" ? <FloatingWindowHarness /> : surface === "floating-window-headerless" ? <HeaderlessFloatingWindowHarness /> : surface === "floating-window-generic" ? <GenericFloatingWindowHarness /> : surface === "task-detail-title-modal" ? <TaskDetailTitleModalHarness /> : surface === "task-detail-title-main-panel" ? <TaskDetailTitleMainPanelHarness /> : surface === "task-detail-title-list" ? <TaskDetailTitleListHarness /> : surface === "task-detail-title-dock" ? <TaskDetailTitleDockHarness /> : surface === "task-detail-title-floating" ? <TaskDetailTitleFloatingHarness /> : surface === "task-detail-title-embedded" ? <TaskDetailTitleEmbeddedHarness /> : surface === "task-detail" ? <TaskDetailResizeHarness /> : <NewTaskModal
isOpen
tasks={[]}
onClose={() => undefined}

View File

@@ -18,14 +18,21 @@ const browserCandidates = process.platform === "darwin"
? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium"]
: ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
const executablePath = [process.env.FUSION_BROWSER_SMOKE_BROWSER, process.env.CHROME_BIN, ...browserCandidates].find((candidate): candidate is string => Boolean(candidate) && existsSync(candidate));
/*
FNXC:TaskDetailTitle 2026-08-05-19:18:
FN-8806's acceptance evidence is real Chromium geometry across production hosts. A skipped browser
lane can neither observe ResizeObserver delivery nor reject the original flicker, so fail discovery
explicitly instead of allowing a green suite without this required rendering-engine regression.
*/
if (!executablePath) {
console.warn(
"[task-modal-touch-resize] Skipping Chromium CDP touch geometry: no browser found via FUSION_BROWSER_SMOKE_BROWSER, CHROME_BIN, or platform candidates.",
throw new Error(
"[task-modal-touch-resize] Chromium is required for task-title stability coverage; set FUSION_BROWSER_SMOKE_BROWSER or CHROME_BIN.",
);
}
const screenshots = path.resolve(process.cwd(), "e2e/__screenshots__/fn-8602");
const floatingWindowScreenshots = path.resolve(process.cwd(), "e2e/__screenshots__/fn-8605");
const fn8607Screenshots = path.resolve(process.cwd(), "e2e/__screenshots__/fn-8607");
const fn8806Screenshots = path.resolve(process.cwd(), "e2e/__screenshots__/fn-8806");
async function touchDrag(cdp: Cdp, point: Point, delta = { x: 48, y: 36 }) {
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: point.x, y: point.y, id: 1 }] });
@@ -115,13 +122,49 @@ async function targetCenter(page: Page, selector: string): Promise<Point> {
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
async function openProductionTitleHost(page: Page, name: string, hostTestId: string) {
await page.evaluate(async ({ name, hostTestId }) => {
const nextFrames = async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
};
await nextFrames();
if (name === "list-split") {
document.querySelector<HTMLElement>("tr[data-id]")?.click();
await nextFrames();
}
if (name === "floating-window") {
for (let frame = 0; frame < 6 && !document.querySelector("[data-id]"); frame++) await nextFrames();
const taskCard = document.querySelector<HTMLElement>("[data-id]");
if (!taskCard) throw new Error("App fixture did not render its live board task before opening the production pop-out path");
taskCard.click();
for (let frame = 0; frame < 6 && !document.querySelector("[data-testid='task-detail-pop-out']"); frame++) await nextFrames();
const popOut = document.querySelector<HTMLButtonElement>("[data-testid='task-detail-pop-out']");
if (!popOut) throw new Error("App board detail did not expose its production Pop out control");
popOut.click();
await nextFrames();
}
let host: HTMLElement | null = null;
let detailContent: HTMLElement | null = null;
for (let frame = 0; frame < 6 && (!host || !detailContent); frame++) {
host = name === "floating-window"
? document.querySelector<HTMLElement>(`[data-testid^='${hostTestId}']`)
: document.querySelector<HTMLElement>(`[data-testid='${hostTestId}']`);
detailContent = name === "floating-window"
? host?.querySelector<HTMLElement>(".task-detail-content") ?? null
: document.querySelector<HTMLElement>(".task-detail-content");
if (!host || !detailContent) await nextFrames();
}
if (!host || !detailContent) throw new Error(`${name} did not render its intended production host and TaskDetailContent (host=${Boolean(host)} content=${Boolean(detailContent)} body=${document.body.textContent?.slice(0, 500) ?? ""})`);
}, { name, hostTestId });
}
/*
FNXC:TaskModalResize 2026-07-26-15:12:
Browser CDP gestures are required because jsdom cannot resolve CSS hit targets. This fixture mounts
both production resize paths and sends CSS-pixel touch input through Chromium so elementFromPoint,
pointer capture, persistence, and header-drag isolation use the same browser input path.
*/
describe.runIf(executablePath)("Task modal tablet touch resize browser regression", () => {
describe("Task modal tablet touch resize browser regression", () => {
let server: ViteDevServer; let browser: Browser; let baseUrl = "";
beforeAll(async () => {
server = await createServer({ root: process.cwd(), server: { host: "127.0.0.1", port: 0, watch: null }, logLevel: "error" });
@@ -510,6 +553,224 @@ describe.runIf(executablePath)("Task modal tablet touch resize browser regressio
await page.close();
}, 30_000);
/*
FNXC:TaskDetailTitle 2026-08-05-17:54:
Chromium samples real TaskDetailModal and embedded TaskDetailContent after multiple animation
frames. Browser layout—not jsdom's fixed geometry—is the regression authority for a title whose
control used to change the same clamp geometry that decided whether the control existed.
*/
/*
FNXC:TaskDetailTitle 2026-08-05-19:01:
Each row enters the actual modal orchestration, board main panel, List split pane, right-dock
controller, or App task pop-out path. The floating row renders App, opens a live board task, and
activates its production Pop out control before sampling. Sample both desktop and constrained widths:
a title control must not change its own overflow eligibility after an operator expands or collapses.
*/
it.each([
["modal", "title-host-modal", "task-detail-title-modal", 1024],
["main-panel", "title-host-main-panel", "task-detail-title-main-panel", 1024],
["list-split", "title-host-list", "task-detail-title-list", 1024],
["right-dock", "title-host-dock", "task-detail-title-dock", 1024],
["floating-window", "floating-window-overlay-task-detail-", "task-detail-title-app-floating", 1024],
["modal", "title-host-modal", "task-detail-title-modal", 720],
["main-panel", "title-host-main-panel", "task-detail-title-main-panel", 720],
["list-split", "title-host-list", "task-detail-title-list", 820],
["right-dock", "title-host-dock", "task-detail-title-dock", 820],
["floating-window", "floating-window-overlay-task-detail-", "task-detail-title-app-floating", 720],
] as const)("keeps the %s production title host %s via %s stable at %dpx after one activation", async (name, hostTestId, surface, width) => {
const page = await browser.newPage({ viewport: { width, height: 844 } });
page.on("pageerror", (message) => console.error(`[task-title-${name}] ${message.message ?? ""}\n${(message as { stack?: string }).stack ?? ""}`));
await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=${surface}&reset=1${name === "floating-window" ? "&project=fixture" : ""}`);
const samples = await page.evaluate(async ({ hostTestId, name }) => {
const nextFrames = async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
};
await nextFrames();
if (name === "list-split") {
document.querySelector<HTMLElement>("tr[data-id='FN-TITLE-FLICKER']")?.click();
await nextFrames();
}
if (name === "floating-window") {
// The fixture hydrates App's production project/task caches, then awaits its React commit
// through animation frames rather than a wall-clock delay.
for (let frame = 0; frame < 6 && !document.querySelector("[data-id='FN-TITLE-FLICKER']"); frame++) await nextFrames();
const taskCard = document.querySelector<HTMLElement>("[data-id='FN-TITLE-FLICKER']");
if (!taskCard) throw new Error("App fixture did not render its live board task before opening the production pop-out path");
taskCard.click();
for (let frame = 0; frame < 6 && !document.querySelector("[data-testid='task-detail-pop-out']"); frame++) await nextFrames();
const popOut = document.querySelector<HTMLButtonElement>("[data-testid='task-detail-pop-out']");
if (!popOut) throw new Error(`App board detail did not expose its production Pop out control; title:${document.querySelector("h2.detail-title")?.textContent ?? "missing"}`);
popOut.click();
await nextFrames();
}
const host = name === "floating-window"
? document.querySelector(`[data-testid^='${hostTestId}']`)
: document.querySelector(`[data-testid='${hostTestId}']`);
const title = name === "floating-window"
? host?.querySelector<HTMLHeadingElement>("h2.detail-title") ?? null
: document.querySelector<HTMLHeadingElement>("h2.detail-title");
const control = name === "floating-window"
? host?.querySelector<HTMLButtonElement>(".detail-title-control") ?? null
: document.querySelector<HTMLButtonElement>(".detail-title-control");
const detailContent = title?.closest<HTMLElement>(".task-detail-content") ?? null;
if (!host || !title || !control || !detailContent) throw new Error(`${name} did not render its production title host, TaskDetailContent, and overflow control (host=${Boolean(host)} title=${Boolean(title)} content=${Boolean(detailContent)} control=${Boolean(control)} aria-hidden=${host?.getAttribute("aria-hidden")}): ${document.querySelector("[data-testid='list-split-detail-content']") ? "split-detail-without-title" : document.querySelector("[data-testid='list-view-body']") ? "list-without-split-detail" : "host-missing"}`);
const controlIdentity = control;
const sample = () => {
const liveTitle = name === "floating-window"
? host.querySelector<HTMLHeadingElement>("h2.detail-title")
: document.querySelector<HTMLHeadingElement>("h2.detail-title");
const liveControl = name === "floating-window"
? host.querySelector<HTMLButtonElement>(".detail-title-control")
: document.querySelector<HTMLButtonElement>(".detail-title-control");
return {
collapsed: liveTitle?.classList.contains("detail-title--collapsed") ?? true,
expanded: liveControl?.getAttribute("aria-expanded") ?? null,
controls: document.querySelectorAll(".detail-title-control").length,
sameControl: liveControl === controlIdentity,
height: liveTitle?.getBoundingClientRect().height ?? 0,
};
};
control.click();
const expanded = [];
for (let frame = 0; frame < 6; frame++) { await nextFrames(); expanded.push(sample()); }
// Resize the live production heading row, then yield native ResizeObserver delivery frames.
// This is intentionally not a fixed jsdom geometry stub: Chromium recomputes the real host's
// flex and clamp layout while the operator-owned expansion remains selected.
const headingRow = title.parentElement;
const originalInlineSize = headingRow?.style.inlineSize ?? "";
const widthBeforeResize = title.getBoundingClientRect().width;
if (headingRow) headingRow.style.inlineSize = "75%";
window.dispatchEvent(new Event("resize"));
const expandedAfterResize = [];
for (let frame = 0; frame < 6; frame++) { await nextFrames(); expandedAfterResize.push(sample()); }
const widthAfterResize = title.getBoundingClientRect().width;
(name === "floating-window"
? host.querySelector<HTMLButtonElement>(".detail-title-control")
: document.querySelector<HTMLButtonElement>(".detail-title-control"))?.click();
const collapsed = [];
for (let frame = 0; frame < 6; frame++) { await nextFrames(); collapsed.push(sample()); }
if (headingRow) headingRow.style.inlineSize = originalInlineSize;
return { expanded, expandedAfterResize, collapsed, widthBeforeResize, widthAfterResize };
}, { hostTestId, name });
expect(samples.expanded.every((sample) => !sample.collapsed && sample.expanded === "true" && sample.controls === 1 && sample.sameControl)).toBe(true);
expect(new Set(samples.expanded.map((sample) => sample.height))).toHaveLength(1);
expect(samples.widthAfterResize).toBeLessThan(samples.widthBeforeResize);
expect(samples.expandedAfterResize.every((sample) => !sample.collapsed && sample.expanded === "true" && sample.controls === 1 && sample.sameControl)).toBe(true);
expect(new Set(samples.expandedAfterResize.map((sample) => sample.height))).toHaveLength(1);
expect(samples.collapsed.every((sample) => sample.collapsed && sample.expanded === "false" && sample.controls === 1 && sample.sameControl)).toBe(true);
expect(new Set(samples.collapsed.map((sample) => sample.height))).toHaveLength(1);
if (name === "modal" && (width === 1024 || width === 720)) {
await mkdir(fn8806Screenshots, { recursive: true });
await page.screenshot({
path: path.join(fn8806Screenshots, width === 1024 ? "task-title-stable-modal-desktop.png" : "task-title-stable-modal-narrow.png"),
});
}
await page.close();
}, 30_000);
/*
FNXC:TaskDetailTitle 2026-08-05-19:39:
FN-8806 requires Chromium—not fixed jsdom dimensions—to prove the no-control fitting state, both
display fallbacks, and a real host-width threshold transition. Every production host and viewport
must keep the control absent when fitting, render exactly one accessible control when overflowing,
and update only eligibility when native ResizeObserver geometry crosses the clamp boundary.
*/
const titleHostRows = [
["modal", "title-host-modal", "task-detail-title-modal", 1024],
["main-panel", "title-host-main-panel", "task-detail-title-main-panel", 1024],
["list-split", "title-host-list", "task-detail-title-list", 1024],
["right-dock", "title-host-dock", "task-detail-title-dock", 1024],
["floating-window", "floating-window-overlay-task-detail-", "task-detail-title-app-floating", 1024],
["modal", "title-host-modal", "task-detail-title-modal", 720],
["main-panel", "title-host-main-panel", "task-detail-title-main-panel", 720],
["list-split", "title-host-list", "task-detail-title-list", 820],
["right-dock", "title-host-dock", "task-detail-title-dock", 820],
["floating-window", "floating-window-overlay-task-detail-", "task-detail-title-app-floating", 720],
] as const;
for (const [titleMode, expectedText, expectsControl] of [
["fit", "Fitting browser title", false],
["description", "A browser measured description fallback", true],
["id", "FN-8806", false],
] as const) {
it.each(titleHostRows)("renders %s host %s via %s at %dpx with title fallback state", async (name, hostTestId, surface, width) => {
const page = await browser.newPage({ viewport: { width, height: 844 } });
page.on("pageerror", (message) => console.error(`[task-title-${name}-${titleMode}] ${message.message ?? ""}`));
await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=${surface}&titleMode=${titleMode}&reset=1${name === "floating-window" ? "&project=fixture" : ""}`);
await openProductionTitleHost(page, name, hostTestId);
const state = await page.evaluate(({ name, hostTestId }) => {
const host = name === "floating-window"
? document.querySelector<HTMLElement>(`[data-testid^='${hostTestId}']`)
: document.querySelector<HTMLElement>(`[data-testid='${hostTestId}']`);
const title = name === "floating-window"
? host?.querySelector<HTMLHeadingElement>("h2.detail-title") ?? null
: document.querySelector<HTMLHeadingElement>("h2.detail-title");
const controls = name === "floating-window"
? host?.querySelectorAll<HTMLButtonElement>(".detail-title-control") ?? []
: document.querySelectorAll<HTMLButtonElement>(".detail-title-control");
return {
text: title?.textContent ?? "",
collapsed: title?.classList.contains("detail-title--collapsed") ?? false,
controls: controls.length,
ariaLabels: [...controls].map((control) => control.getAttribute("aria-label")),
expanded: [...controls].map((control) => control.getAttribute("aria-expanded")),
};
}, { name, hostTestId });
expect(state.text).toContain(expectedText);
expect(state.collapsed).toBe(true);
expect(state.controls).toBe(expectsControl ? 1 : 0);
expect(state.ariaLabels).toEqual(expectsControl ? ["Expand task title"] : []);
expect(state.expanded).toEqual(expectsControl ? ["false"] : []);
await page.close();
}, 30_000);
}
it.each(titleHostRows)("crosses the clamp threshold in real Chromium for %s host %s via %s at %dpx", async (name, hostTestId, surface, width) => {
const page = await browser.newPage({ viewport: { width, height: 844 } });
await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=${surface}&titleMode=threshold&reset=1${name === "floating-window" ? "&project=fixture" : ""}`);
await openProductionTitleHost(page, name, hostTestId);
const state = await page.evaluate(async ({ name, hostTestId }) => {
const nextFrames = async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
};
const host = name === "floating-window"
? document.querySelector<HTMLElement>(`[data-testid^='${hostTestId}']`)
: document.querySelector<HTMLElement>(`[data-testid='${hostTestId}']`);
const title = name === "floating-window"
? host?.querySelector<HTMLHeadingElement>("h2.detail-title")
: document.querySelector<HTMLHeadingElement>("h2.detail-title");
const headingRow = title?.parentElement as HTMLElement | null;
if (!title || !headingRow) throw new Error(`${name} did not retain its production title heading row`);
const sample = () => ({
width: title.getBoundingClientRect().width,
controls: name === "floating-window"
? host?.querySelectorAll(".detail-title-control").length ?? 0
: document.querySelectorAll(".detail-title-control").length,
collapsed: title.classList.contains("detail-title--collapsed"),
});
headingRow.style.inlineSize = "100rem";
window.dispatchEvent(new Event("resize"));
for (let frame = 0; frame < 6; frame++) await nextFrames();
const fitting = sample();
headingRow.style.inlineSize = "12rem";
window.dispatchEvent(new Event("resize"));
for (let frame = 0; frame < 6; frame++) await nextFrames();
const overflowing = sample();
headingRow.style.inlineSize = "100rem";
window.dispatchEvent(new Event("resize"));
for (let frame = 0; frame < 6; frame++) await nextFrames();
const fittingAgain = sample();
return { fitting, overflowing, fittingAgain };
}, { name, hostTestId });
expect(state.fitting.width).toBeGreaterThan(state.overflowing.width);
expect(state.fitting).toEqual({ width: state.fitting.width, controls: 0, collapsed: true });
expect(state.overflowing).toEqual({ width: state.overflowing.width, controls: 1, collapsed: true });
expect(state.fittingAgain).toEqual({ width: state.fittingAgain.width, controls: 0, collapsed: true });
await page.close();
}, 30_000);
it("keeps the true-phone sheet free of active resize targets", async () => {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
const cdp = await page.context().newCDPSession(page);