Files
fusion/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx
gsxdsm 83a33be353 feat(dashboard): persistent sign-in dialog for paste-back provider logins
Operator report: during a container login there was nowhere obvious to paste the
redirect URL and no sign of what the app was waiting on. The flow was split
across a pre-flight confirm that warned about paste-back and vanished, a card
that shrank to a disabled "Waiting for login…" chip, and the paste field
rendered inline in that card below the fold of a scrolling modal.

ProviderLoginDialog now opens with the flow and stays until it ends: a two-step
progress list, a button to re-open a lost sign-in tab, the paste field, and the
terminal outcome inline instead of a toast that disappears while the operator is
in another browser tab.

Three defects found and fixed while verifying it in a real container:

- It sank behind the onboarding modal and clicks landed on the modal instead.
  createPortal relocates the DOM node but NOT the React tree, so pointer events
  bubbled to the host FloatingWindow, which raises itself to a fresh
  nextFloatingZ() on every pointerdown — each click in the dialog lifted the
  window above it. Fixed by rendering the dialog as a sibling of the window,
  claiming z once on open (it was calling nextFloatingZ() inline on every render
  of a modal that re-renders on a 2s poll), and stopping propagation on the
  overlay. Ratcheted for every portaled .modal-overlay.
- Spacing did not match any other dialog: it hand-rolled header/action padding
  instead of using .modal-header/.modal-actions, and padded each child
  separately. Every row now shares var(--modal-padding) — verified at a uniform
  17px inset across header, steps, paste prompt, field, Submit, and actions.
- The paste field was invisible (.form-input fills with var(--surface), and so
  does .modal — measured #0c0c0e on #0c0c0e), Submit was a 25px row-density
  btn-sm, and both could scroll out of reach. The field now sinks to var(--bg)
  with var(--border-strong), Submit takes standard control padding, and the
  paste region is pinned outside the scroll area.

Dialog anatomy rules (spacing primitives, portal/stacking) documented in
docs/dashboard-guide.md.

Verified: 441 dashboard tests including 4 new dialog tests, eslint, dashboard
typecheck, and the rendered dialog measured in a container build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:47:28 -07:00

1308 lines
57 KiB
TypeScript

import { readdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { render, screen, fireEvent } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadAllAppCss, loadStylesCss } from "../../test/cssFixture";
import { FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, FloatingWindow } from "../FloatingWindow";
import { readAppFile } from "../../test/cssFixture";
import { dragWithTouch, expectFloatingWindowStructure, resizeWithTouch } from "./floatingWindowMigration.test-helpers";
const floatingWindowCss = readAppFile("components/FloatingWindow.css");
const chatViewCss = readAppFile("components/ChatView.css");
const allAppCss = loadAllAppCss();
const stylesCss = loadStylesCss();
const FN_8606_WINDOW_IDENTITIES = [
["ActivityLogModal.tsx", "activity-log"], ["ScriptsModal.tsx", "scripts"], ["ScheduledTasksModal.tsx", "automation"],
["SettingsModal.tsx", "settings"], ["GitManagerModal.tsx", "git-manager"], ["PlanningModeModal.tsx", "planning-mode"],
["ChangesDiffModal.tsx", "changes-diff"], ["ModelOnboardingModal.tsx", "model-onboarding"], ["AddNodeModal.tsx", "add-node"],
["ConnectNodeModal.tsx", "connect-node"], ["NodeDetailModal.tsx", "node-detail"], ["WorkflowAddStepModal.tsx", "workflow-add-step"],
["GroupTaskModal.tsx", "group-task"],
] as const;
const QUICK_CHAT_PORTALED_MENU_CLASSES = [
"model-combobox-dropdown--portal",
"model-nested-menu--portal",
"dep-dropdown--portal",
"node-picker-dropdown--portal",
"agent-picker-dropdown--portal",
"priority-picker-dropdown--portal",
"activity-view-menu",
] as const;
function cssRuleFor(css: string, selector: string): string {
const start = css.indexOf(`${selector} {`);
if (start === -1) return "";
const end = css.indexOf("}", start);
return css.slice(start, end);
}
function cssRuleContaining(css: string, selector: string, declaration: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\ /g, "\\s+");
const matches = css.matchAll(new RegExp(`${escaped}\\s*\\{[^}]*\\}`, "g"));
for (const match of matches) {
if (match[0].includes(declaration)) return match[0];
}
return "";
}
function cssRulesForClass(css: string, className: string): string[] {
const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return [...css.matchAll(new RegExp(`\\.${escaped}[^{}]*\\{[^}]*\\}`, "g"))].map((match) => match[0]);
}
/*
FNXC:FloatingWindow 2026-07-17-08:20:
The FN-8015 desktop resize-hot-zone invariant only governs desktop widths. The
mobile full-screen sheet variants hide every resize handle, so removing the
inherited body gutter there is legitimate (and required — see the mobile
task-detail left-shift fix). Strip `@media` blocks with balanced-brace matching
before scanning so the desktop invariant ignores mobile-only overrides.
*/
function stripAtMediaBlocks(css: string): string {
let out = "";
let i = 0;
while (i < css.length) {
const at = css.indexOf("@media", i);
if (at === -1) {
out += css.slice(i);
break;
}
out += css.slice(i, at);
const open = css.indexOf("{", at);
if (open === -1) break;
let depth = 1;
let j = open + 1;
while (j < css.length && depth > 0) {
if (css[j] === "{") depth++;
else if (css[j] === "}") depth--;
j++;
}
i = j;
}
return out;
}
function mediaBlockFor(css: string, query: string): string {
const start = css.indexOf(`@media ${query}`);
if (start === -1) return "";
const open = css.indexOf("{", start);
let depth = 1;
let index = open + 1;
while (index < css.length && depth > 0) {
if (css[index] === "{") depth++;
else if (css[index] === "}") depth--;
index++;
}
return css.slice(open + 1, index - 1);
}
function setSheetViewport(isSheetWidth: boolean): void {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: query === "(max-width: 767.98px)" ? isSheetWidth : query === "(max-height: 480px)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
}
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Contract tests for the reusable non-blocking floating window:
- the overlay is click-through (pointer-events:none) so the page and other windows behind it stay interactive,
- the panel re-enables pointer events and carries a header drag handle + resize handles,
- focus-to-front raises this window's z-index above any previously-opened window,
- close removes the window (onClose fires).
JSDOM has no real layout/pointer-capture, so drag math is asserted in the RightDockExpandModal pattern's own suite; here we assert the structural + stacking contract that makes multiple coexisting windows non-blocking.
*/
describe("FloatingWindow", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("renders a non-blocking, click-through transparent overlay with a pointer-events:auto panel", () => {
render(
<FloatingWindow windowKey="alpha" title="Alpha" onClose={() => {}}>
<div>alpha body</div>
</FloatingWindow>
);
const overlay = screen.getByTestId("floating-window-overlay-alpha");
// styles.css is not loaded here, so assert via the class contract the CSS attaches pointer-events:none to.
expect(overlay.className).toContain("floating-window-overlay");
const panel = screen.getByTestId("floating-window-alpha");
expect(panel.className).toContain("floating-window");
// Panel is positioned/stacked via inline style.
expect(panel.style.position === "" || panel.style.left).toBeDefined();
expect(panel.style.zIndex).not.toBe("");
});
it("exposes a header drag handle and resize handles", () => {
render(
<FloatingWindow windowKey="beta" title="Beta" onClose={() => {}}>
<div>beta body</div>
</FloatingWindow>
);
expect(screen.getByTestId("floating-window-drag-handle-beta")).toBeTruthy();
expectFloatingWindowStructure("beta");
});
/*
FNXC:FloatingWindow 2026-08-17-23:47:
RATCHET: the shared body carries NO inline-end gutter, on any breakpoint, for any caller.
FN-8015 reserved `margin-inline-end: var(--space-lg)` there so a hosted scrollbar cleared the east
resize hot zones; operator removed it because one shared reservation every caller had to know about
produced a recurring class of asymmetric-right-inset bugs (FN-8630, FN-8634, FN-8702, FN-8722,
FN-8766, the 2026-08-01 tablet fix, and the Set Up AI onboarding report) — the window surface showed
through beside each child panel. It was also being zeroed piecemeal in five places, so the "shared"
default applied to a shrinking minority.
This asserts the ABSENCE globally rather than per-caller: a reintroduction anywhere — base rule,
a caller override, or inside any `@media` block — fails here. Where a scrollbar and a resize target
genuinely collide, move that caller's east targets outboard (the FN-8766 pattern asserted below).
*/
it("keeps the shared floating-window body free of any inline-end gutter", () => {
const bodyRule = floatingWindowCss.match(/(?:^|\n)\.floating-window__body\s*\{[^}]*\}/)?.[0] ?? "";
expect(bodyRule).toContain("overflow: auto;");
expect(bodyRule).not.toMatch(/margin-inline-end\s*:/);
/*
* No rule in ANY app stylesheet, at any breakpoint, may set an inline-end margin on the shared
* body. Comments are stripped first: the FNXC notes explaining WHY the gutter is gone name the
* property, and must not read as a reintroduction.
*/
const appCssWithoutComments = allAppCss.replace(/\/\*[\s\S]*?\*\//g, "");
const bodyRulesEverywhere = [...appCssWithoutComments.matchAll(/[^{}]*\.floating-window__body[^{}]*\{[^}]*\}/g)].map((m) => m[0]);
expect(bodyRulesEverywhere.length).toBeGreaterThan(0);
for (const rule of bodyRulesEverywhere) {
expect(/margin-inline-end\s*:/.test(rule), rule.slice(0, 120)).toBe(false);
}
// Shared handles stay flush with the painted edge; only FN-8766's task-detail host moves outboard.
expect(stylesCss).toContain("*::-webkit-scrollbar {");
expect(stylesCss).toContain("width: 8px;");
expect(cssRuleContaining(floatingWindowCss, ".floating-window__resize-handle--e", "right: 0;")).toContain("right: 0;");
expect(cssRuleContaining(floatingWindowCss, ".floating-window__resize-handle--ne", "right: 0;")).toContain("right: 0;");
expect(cssRuleContaining(floatingWindowCss, ".floating-window__resize-handle--se", "right: 0;")).toContain("right: 0;");
const desktopAppCss = stripAtMediaBlocks(allAppCss);
for (const callerClass of [
"floating-window--automation",
"floating-window--mission-interview",
"floating-window--pr-create",
"floating-window--file-browser",
"floating-window--workflow-editor",
"artifacts-gallery-window",
]) {
const rules = cssRulesForClass(desktopAppCss, callerClass);
const rightHandleRules = rules.filter((rule) => /floating-window__resize-handle(?:--(?:e|ne|se))?/.test(rule));
expect(rightHandleRules.some((rule) => /(?:right|width)\s*:/.test(rule)), callerClass).toBe(false);
}
/*
FNXC:FloatingWindow 2026-08-18-00:26:
FN-8766's outboard east targets are promoted from a task-detail special case to the SHARED
desktop contract: with the gutter gone a hosted scrollbar sits flush against the painted edge,
and moving the hit areas outside the shell is what keeps it grabbable (issue #2140) without
insetting anything. That needs the host to stop clipping, so the body and its direct child take
over the corner radius — only 8 of ~30 callers set that themselves, and the rest would paint
square corners over the rounded shell.
*/
expect(cssRuleContaining(desktopAppCss, ".floating-window:not(.floating-window--tablet-viewport)", "overflow: visible;")).toContain("overflow: visible;");
expect(cssRuleContaining(desktopAppCss, ".floating-window:not(.floating-window--tablet-viewport) .floating-window__resize-handle--e", "right")).toContain("right: calc(var(--space-sm) * -1);");
// The corner targets share one grouped rule, so match the block rather than a bare selector.
const outboardCorners = desktopAppCss.match(
/\.floating-window:not\(\.floating-window--tablet-viewport\) \.floating-window__resize-handle--ne,[\s\S]*?\}/
)?.[0] ?? "";
expect(outboardCorners).toContain("right: calc(var(--space-lg) * -1);");
expect(outboardCorners).toContain("resize-handle--se");
const paintedClipping = floatingWindowCss.match(/\.floating-window__body,\s*\n\.floating-window__body > \*\s*\{[^}]*\}/)?.[0] ?? "";
expect(paintedClipping).toContain("border-radius: inherit;");
// Phones hide every handle, so they need no outboard room and must keep clipping their sheets.
const phoneSheet = mediaBlockFor(floatingWindowCss, "(max-width: 767.98px), (max-height: 480px)");
expect(cssRuleFor(phoneSheet, ".floating-window")).toContain("overflow: hidden;");
/*
FNXC:GitHubImport 2026-08-17-23:47:
The import detail panel borrowed its right inset from the gutter (`padding-inline-end: 0` plus a
tablet-only restore). With the gutter gone it must own a symmetric inset itself, or its Preview
header goes flush against the window edge — the bug the borrowed gutter originally papered over.
*/
const importPanel = cssRuleFor(allAppCss, ".github-import-detail-panel");
expect(importPanel).toContain("padding: var(--space-lg);");
expect(importPanel).not.toMatch(/padding-inline-end\s*:/);
// Headerless and chat variants replace only body overflow.
expect(cssRuleFor(floatingWindowCss, ".floating-window--headerless .floating-window__body")).toContain("overflow: hidden;");
expect(cssRuleFor(floatingWindowCss, ".floating-window--chat.floating-window--headerless .floating-window__body")).toContain("overflow: hidden;");
});
/*
FNXC:FloatingWindow 2026-08-18-04:20:
RATCHET: a portaled `.modal-overlay` must swallow its own pointer events.
`createPortal` relocates the DOM node but NOT the React tree, so events raised inside a portaled
dialog still bubble to whichever component rendered it. Every FloatingWindow raises itself to a
fresh `nextFloatingZ()` on pointerdown/focus, so a dialog portaled from inside a window's subtree
lifts that window ABOVE itself on first click — after which clicks land on the window behind
(reported on the Set Up AI login dialog as "it keeps getting covered … any click goes to the dialog
below"). Two things prevent it: render the dialog as a SIBLING of the window, and stop propagation
at the overlay. This asserts the second for every such component, since the first is per-caller.
*/
it("keeps every portaled modal overlay from leaking pointer events to its host", () => {
const componentsDir = resolve(__dirname, "..");
const offenders: string[] = [];
let checked = 0;
for (const file of readdirSync(componentsDir).filter((name) => name.endsWith(".tsx"))) {
const source = readFileSync(resolve(componentsDir, file), "utf8");
if (!source.includes("createPortal") || !source.includes("modal-overlay")) continue;
checked++;
if (!source.includes("stopPropagation")) offenders.push(file);
}
expect(checked, "expected portaled overlay components to scan").toBeGreaterThan(0);
expect(offenders, "portaled overlays must stop pointer propagation to their React-tree host").toEqual([]);
});
/*
FNXC:Onboarding 2026-08-17-23:47:
A FloatingWindow paints its own bordered surface, so a hosted child that does not fill it leaves
blank window surface around the content — read by the operator as unexplained right/bottom padding
on first-run Set Up AI. The onboarding modal's standalone sizing rules tie on specificity with the
shared host fill and won on source order, so the fill is re-asserted under the host class.
*/
it("makes the hosted onboarding modal fill its floating window", () => {
// The fill is one grouped rule (`:not([style*="width"])`, `:not([style*="height"])`), so match the block.
const hostedFill = allAppCss.match(
/\.floating-window--model-onboarding \.model-onboarding-modal:not\(\[style\*="width"\]\)[^{]*\{[^}]*\}/
)?.[0] ?? "";
expect(hostedFill).toContain("width: 100%;");
expect(hostedFill).toContain("height: 100%;");
expect(hostedFill).toContain("max-height: none;");
// The window frame owns resizing once hosted; a 640px floor would push content under the east edge.
expect(hostedFill).toContain("min-width: 0;");
expect(hostedFill).toContain("resize: none;");
});
/*
FNXC:FloatingWindow 2026-07-25-00:00:
Regression guard for the landscape-tablet right-inset gap: the width-gated
769-1024px carve-out let iPad Air/Pro landscape (1180-1366 CSS px) fall back to
the desktop contract, so the task pop-up's content stopped short of the right
edge while the left edge stayed flush. Assert the input-device-gated block
applies at ANY width and stays scoped to task-detail.
The body-gutter half of this guard is retired: the shared gutter that produced
the original right-inset gap is deleted outright (see the ratchet above), so
there is no longer a desktop-vs-tablet gutter difference to police here.
*/
it("uses the tablet-touch discriminator instead of bare coarse-pointer suppression", () => {
expect(floatingWindowCss).not.toContain("@media (pointer: coarse)");
expect(floatingWindowCss).not.toContain("max-width: 768px");
expect(floatingWindowCss).toContain("@media (max-width: 767.98px)");
expect(floatingWindowCss).toContain(".floating-window--touch-geometry .floating-window__resize-handle");
expect(floatingWindowCss).toContain("width: var(--modal-resize-touch-target);");
const phoneBlock = mediaBlockFor(floatingWindowCss, "(max-width: 767.98px)");
expect(cssRuleFor(phoneBlock, ".floating-window--task-detail .floating-window__resize-handle")).toContain("display: none;");
});
/*
FNXC:ModalTouchGeometry 2026-08-01-03:48:
Tablet MODE is its own styling marker, distinct from `--touch-geometry`: a non-touch window
at tablet widths must still receive `floating-window--tablet-viewport` so the FN-8015 gutter zeroing
applies everywhere the app classifies the viewport as tablet.
*/
it("marks tablet-mode windows with floating-window--tablet-viewport even without touch", () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: query === "(min-width: 769px) and (max-width: 1024px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})));
render(
<FloatingWindow windowKey="tablet-mode" title="Tablet" onClose={() => {}}>
<div>tablet body</div>
</FloatingWindow>
);
const panel = screen.getByTestId("floating-window-tablet-mode");
expect(panel.className).toContain("floating-window--tablet-viewport");
});
it("keeps task-detail long content clear of right handles while preserving short-content right-edge resize", () => {
const longContent = Array.from({ length: 40 }, (_, index) => <p key={index}>Scrollable task detail {index}</p>);
const { unmount } = render(
<FloatingWindow
windowKey="task-long-content"
title="FN-8015"
onClose={() => {}}
hideHeader
dragHandleSelector=".task-detail-content--embedded > .modal-header"
className="floating-window--task-detail"
>
<div className="task-detail-content--embedded">
<div className="modal-header">FN-8015</div>
<div>{longContent}</div>
</div>
</FloatingWindow>
);
expect(screen.getByTestId("floating-window-body-task-long-content")).toHaveClass("floating-window__body");
for (const direction of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
expect(screen.getByTestId(`floating-window-resize-${direction}`)).toBeTruthy();
}
unmount();
render(
<FloatingWindow
windowKey="task-short-content"
title="FN-8015"
onClose={() => {}}
defaultSize={{ width: 320, height: 240 }}
defaultPosition={{ x: 80, y: 90 }}
minSize={{ width: 240, height: 180 }}
className="floating-window--task-detail"
>
<div>Short task detail</div>
</FloatingWindow>
);
const panel = screen.getByTestId("floating-window-task-short-content");
const eastHandle = screen.getByTestId("floating-window-resize-e");
Object.defineProperty(eastHandle, "setPointerCapture", { configurable: true, value: vi.fn() });
Object.defineProperty(eastHandle, "releasePointerCapture", { configurable: true, value: vi.fn() });
fireEvent.pointerDown(eastHandle, { pointerId: 31, clientX: 400, clientY: 180 });
fireEvent.pointerMove(eastHandle, { pointerId: 31, clientX: 440, clientY: 180 });
fireEvent.pointerUp(eastHandle, { pointerId: 31, clientX: 440, clientY: 180 });
expect(panel.style.width).toBe("360px");
});
it("uses a theme-overridable gentle shadow token instead of an undefined shadow", () => {
const windowRule = floatingWindowCss.match(/\.floating-window\s*\{([^}]*)\}/)?.[1] ?? "";
expect(windowRule).toContain("--floating-window-shadow: var(--shadow-lg);");
expect(windowRule).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));");
expect(floatingWindowCss).not.toContain("var(--shadow-xl)");
});
it("keeps movable mobile drag handles opted out of the pan-y touch lockdown", () => {
expect(allAppCss).toContain("html,");
expect(allAppCss).toContain("body {");
expect(allAppCss).toContain("touch-action: pan-y;");
expect(allAppCss).toContain("* {");
expect(allAppCss).toContain("#root {");
const movableFloatingWindowSelector = ".floating-window:not(.floating-window--chat):not(.floating-window--github-import-detail):not(.floating-window--task-detail):not(.floating-window--workflow-editor):not(.floating-window--automation):not(.floating-window--mission-interview):not(.floating-window--file-browser):not(.floating-window--pr-create):not(.floating-window--activity-log):not(.floating-window--scripts):not(.floating-window--add-node):not(.floating-window--connect-node):not(.floating-window--node-detail):not(.floating-window--workflow-add-step):not(.floating-window--group-task):not(.floating-window--changes-diff):not(.floating-window--model-onboarding):not(.floating-window--git-manager):not(.floating-window--settings):not(.floating-window--planning-mode):not(.artifacts-gallery-window) .floating-window__header";
expect(cssRuleFor(floatingWindowCss, movableFloatingWindowSelector)).toContain("touch-action: none;");
for (const selector of [
".right-dock-expand-modal__header--draggable",
]) {
expect(cssRuleFor(allAppCss, selector)).toContain("touch-action: none;");
}
});
it("maps every FN-8606 modal to the required shared window identity and sheet suspension", () => {
for (const [file, windowKey] of FN_8606_WINDOW_IDENTITIES) {
const source = readAppFile(`components/${file}`);
expect(source, file).toContain(`<FloatingWindow`);
expect(source, file).toContain(`windowKey=\"${windowKey}\"`);
expect(source, file).toContain(`className=\"floating-window--${windowKey}\"`);
expect(source, file).toContain(`persistGeometryKey=\"floating-window:${windowKey}\"`);
expect(source, file).toContain("suspendGeometryPersistenceOnMobile");
expect(source, file).toContain("suspendGeometryPersistenceOnShortViewport");
}
});
it("gives only delegated Quick Chat headers a larger tablet touch target", () => {
const tabletRule = mediaBlockFor(
chatViewCss,
"(min-width: 769px) and (max-width: 1024px) and (min-height: 481px)",
);
const floatingHeaderRule = cssRuleFor(chatViewCss, ".chat-view--floating .view-header");
expect(tabletRule).toContain(".chat-view--floating .view-header");
expect(tabletRule).toContain("min-height: calc(var(--view-header-min-height) + var(--space-sm));");
expect(tabletRule).toContain("height: calc(var(--view-header-min-height) + var(--space-sm));");
expect(floatingHeaderRule).toContain("cursor: grab;");
expect(floatingHeaderRule).toContain("user-select: none;");
expect(floatingHeaderRule).toContain("touch-action: none;");
// The explicit tablet query leaves the ≤768px sheet and >1024px desktop header geometry canonical.
expect(chatViewCss).not.toMatch(/@media \(max-width: 768px\)\s*\{\s*\.chat-view--floating \.view-header\s*\{/);
expect(chatViewCss).not.toMatch(/@media \(min-width: 1025px\)[\s\S]*\.chat-view--floating \.view-header/);
});
it("keeps every tablet movable-modal drag handle on the explicit touch-action none contract", () => {
const tabletStylesStart = stylesCss.indexOf("@media (min-width: 769px) and (max-width: 1024px)");
const mobileStylesStart = stylesCss.indexOf("@media (max-width: 768px)", tabletStylesStart);
expect(tabletStylesStart).toBeGreaterThan(-1);
expect(mobileStylesStart).toBeGreaterThan(tabletStylesStart);
const tabletBlock = stylesCss.slice(tabletStylesStart, mobileStylesStart);
expect(tabletBlock).not.toContain("* {");
expect(tabletBlock).not.toContain("touch-action: pan-y;");
for (const selector of [
".floating-window__header",
".floating-window--headerless .task-detail-content--embedded > .modal-header",
".chat-view--floating .view-header",
".floating-window--workflow-editor .wf-editor-header",
".floating-window--automation .automation-modal__drag-handle",
".floating-window--mission-interview .mission-interview-modal__drag-handle",
".floating-window--pr-create .pr-create-modal__drag-handle",
".file-browser-modal-header",
".artifacts-gallery-viewer-header",
".right-dock-expand-modal__header--draggable",
".new-task-modal__header--draggable",
".quick-chat-fab",
]) {
expect(cssRuleContaining(allAppCss, selector, "touch-action: none;"), selector).toContain("touch-action: none;");
}
});
it("moves a visible-header window through the captured touch drag path", () => {
render(
<FloatingWindow
windowKey="touch-drag"
title="A very long movable floating window title that still starts drag from the ellipsized title text"
onClose={() => {}}
defaultSize={{ width: 320, height: 240 }}
defaultPosition={{ x: 80, y: 90 }}
minSize={{ width: 240, height: 180 }}
>
<div>touch drag body</div>
</FloatingWindow>
);
const panel = screen.getByTestId("floating-window-touch-drag");
const header = screen.getByTestId("floating-window-drag-handle-touch-drag");
const titleText = screen.getByText(/very long movable floating window title/i);
const setPointerCapture = vi.fn();
const releasePointerCapture = vi.fn();
Object.defineProperty(header, "setPointerCapture", { configurable: true, value: setPointerCapture });
Object.defineProperty(header, "releasePointerCapture", { configurable: true, value: releasePointerCapture });
fireEvent.pointerDown(titleText, { pointerId: 17, pointerType: "touch", clientX: 100, clientY: 120 });
fireEvent.pointerMove(header, { pointerId: 17, pointerType: "touch", clientX: 140, clientY: 150 });
fireEvent.pointerUp(header, { pointerId: 17, pointerType: "touch", clientX: 140, clientY: 150 });
expect(setPointerCapture).toHaveBeenCalledWith(17);
expect(releasePointerCapture).toHaveBeenCalledWith(17);
expect(panel.style.left).toBe("120px");
expect(panel.style.top).toBe("120px");
});
it("can hide generic chrome and delegate dragging to a child header", () => {
render(
<FloatingWindow
windowKey="task"
title="KB-001"
onClose={() => {}}
hideHeader
dragHandleSelector=".task-detail-content--embedded > .modal-header"
className="floating-window--task-detail"
>
<div className="task-detail-content--embedded">
<div className="modal-header">KB-001</div>
<div>task body</div>
</div>
</FloatingWindow>
);
expect(screen.queryByTestId("floating-window-drag-handle-task")).toBeNull();
expect(screen.getByTestId("floating-window-task")).toHaveClass("floating-window--headerless");
expect(screen.getByTestId("floating-window-task")).toHaveClass("floating-window--task-detail");
expect(screen.getByText("KB-001")).toBeInTheDocument();
for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
expect(screen.getByTestId(`floating-window-resize-${dir}`)).toBeTruthy();
}
});
it("moves a headerless delegated handle through the captured tablet touch drag path", () => {
render(
<FloatingWindow
windowKey="artifacts-delegate"
title="Artifacts"
onClose={() => {}}
hideHeader
dragHandleSelector=".artifacts-gallery-viewer-header"
className="artifacts-gallery-window"
defaultSize={{ width: 320, height: 240 }}
defaultPosition={{ x: 90, y: 110 }}
minSize={{ width: 240, height: 180 }}
>
<div className="artifacts-gallery-viewer-header">Artifacts header</div>
<div aria-label="empty artifacts body" />
</FloatingWindow>
);
const panel = screen.getByTestId("floating-window-artifacts-delegate");
const delegatedHeader = screen.getByText("Artifacts header");
const setPointerCapture = vi.fn();
const releasePointerCapture = vi.fn();
Object.defineProperty(panel, "setPointerCapture", { configurable: true, value: setPointerCapture });
Object.defineProperty(panel, "releasePointerCapture", { configurable: true, value: releasePointerCapture });
fireEvent.pointerDown(delegatedHeader, { pointerId: 23, pointerType: "touch", clientX: 120, clientY: 140 });
fireEvent.pointerMove(panel, { pointerId: 23, pointerType: "touch", clientX: 150, clientY: 170 });
fireEvent.pointerUp(panel, { pointerId: 23, pointerType: "touch", clientX: 150, clientY: 170 });
expect(setPointerCapture).toHaveBeenCalledWith(23);
expect(releasePointerCapture).toHaveBeenCalledWith(23);
expect(panel.style.left).toBe("120px");
expect(panel.style.top).toBe("140px");
});
it("scopes mobile sheet sizing and hidden resize handles to task-detail pop-outs", () => {
expect(floatingWindowCss).toContain("FNXC:MobileTaskPopups 2026-06-29-00:00");
expect(floatingWindowCss).toContain(".floating-window--task-detail {");
expect(floatingWindowCss).toContain("width: 100vw !important;");
expect(floatingWindowCss).toContain("height: 100dvh !important;");
expect(floatingWindowCss).toContain(".floating-window--task-detail .floating-window__resize-handle");
expect(floatingWindowCss).toContain("display: none;");
expect(floatingWindowCss).toContain("cursor: default;");
expect(floatingWindowCss).toContain("touch-action: auto;");
});
it("does not apply task-detail mobile sizing to chat floating windows", () => {
const taskRuleIndex = floatingWindowCss.indexOf(".floating-window--task-detail {");
const chatRuleIndex = floatingWindowCss.indexOf(".floating-window--chat {");
expect(taskRuleIndex).toBeGreaterThan(-1);
expect(chatRuleIndex).toBeGreaterThan(-1);
expect(taskRuleIndex).not.toBe(chatRuleIndex);
});
it("focus-to-front: interacting with an older utility window raises its z-index above the newest utility window", () => {
render(
<>
<FloatingWindow windowKey="first" title="First" onClose={() => {}}>
<div>first</div>
</FloatingWindow>
<FloatingWindow windowKey="second" title="Second" onClose={() => {}}>
<div>second</div>
</FloatingWindow>
</>
);
const first = screen.getByTestId("floating-window-first");
const second = screen.getByTestId("floating-window-second");
// Second mounted last → starts on top.
expect(Number(second.style.zIndex)).toBeGreaterThan(Number(first.style.zIndex));
// Clicking the first panel raises it above the second.
fireEvent.pointerDown(first);
expect(Number(first.style.zIndex)).toBeGreaterThan(Number(second.style.zIndex));
});
it("keeps task-detail popups in the board layer while allowing raise among task popups", () => {
render(
<>
<FloatingWindow windowKey="task-a" title="Task A" onClose={() => {}} layer="task-detail" className="floating-window--task-detail">
<div>task a</div>
</FloatingWindow>
<FloatingWindow windowKey="task-b" title="Task B" onClose={() => {}} layer="task-detail" className="floating-window--task-detail">
<div>task b</div>
</FloatingWindow>
<FloatingWindow windowKey="utility" title="Utility" onClose={() => {}}>
<div>utility</div>
</FloatingWindow>
</>,
);
const taskA = screen.getByTestId("floating-window-task-a");
const taskB = screen.getByTestId("floating-window-task-b");
const utility = screen.getByTestId("floating-window-utility");
const taskAOverlay = screen.getByTestId("floating-window-overlay-task-a");
const utilityOverlay = screen.getByTestId("floating-window-overlay-utility");
expect(Number(taskB.style.zIndex)).toBeGreaterThan(Number(taskA.style.zIndex));
expect(Number(utility.style.zIndex)).toBeGreaterThan(Number(taskB.style.zIndex));
expect(Number(utilityOverlay.style.zIndex)).toBeGreaterThan(Number(taskAOverlay.style.zIndex));
fireEvent.pointerDown(taskA);
expect(Number(taskA.style.zIndex)).toBeGreaterThan(Number(taskB.style.zIndex));
expect(Number(taskA.style.zIndex)).toBeLessThan(Number(utility.style.zIndex));
});
it("close button removes the window via onClose", () => {
const onClose = vi.fn();
render(
<FloatingWindow windowKey="gamma" title="Gamma" onClose={onClose}>
<div>gamma body</div>
</FloatingWindow>
);
fireEvent.click(screen.getByTestId("floating-window-close-gamma"));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("closes on outside pointerdown only when the opt-in prop is enabled", () => {
const onClose = vi.fn();
render(
<FloatingWindow windowKey="outside-close" title="Outside close" onClose={onClose} closeOnOutsidePointerDown>
<div>inside body</div>
</FloatingWindow>
);
fireEvent.pointerDown(document.body);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not close for inside pointerdown when outside dismissal is enabled", () => {
const onClose = vi.fn();
render(
<FloatingWindow windowKey="inside-safe" title="Inside safe" onClose={onClose} closeOnOutsidePointerDown>
<button type="button">Inside action</button>
</FloatingWindow>
);
fireEvent.pointerDown(screen.getByText("Inside action"));
fireEvent.pointerDown(screen.getByTestId("floating-window-body-inside-safe"));
fireEvent.pointerDown(screen.getByTestId("floating-window-inside-safe"));
expect(onClose).not.toHaveBeenCalled();
});
it("keeps page clicks non-dismissive by default for persistent floating windows", () => {
const onClose = vi.fn();
render(
<FloatingWindow windowKey="persistent" title="Persistent" onClose={onClose}>
<div>persistent body</div>
</FloatingWindow>
);
fireEvent.pointerDown(document.body);
expect(onClose).not.toHaveBeenCalled();
});
it("does not close on outside pointerdown when the opt-in prop is explicitly false", () => {
const onClose = vi.fn();
render(
<FloatingWindow windowKey="outside-disabled" title="Outside disabled" onClose={onClose} closeOnOutsidePointerDown={false}>
<div>chat body</div>
</FloatingWindow>
);
fireEvent.pointerDown(document.body);
expect(onClose).not.toHaveBeenCalled();
});
it("does not close when the outside target is another floating or dialog surface", () => {
for (const surfaceClassOrRole of ["modal-overlay", "floating-window", "dialog-role"] as const) {
const onClose = vi.fn();
const { unmount } = render(
<FloatingWindow windowKey={`nested-${surfaceClassOrRole}`} title="Nested safe" onClose={onClose} closeOnOutsidePointerDown>
<div>chat body</div>
</FloatingWindow>
);
const surface = document.createElement("div");
if (surfaceClassOrRole === "dialog-role") {
surface.setAttribute("role", "dialog");
} else {
surface.className = surfaceClassOrRole;
}
document.body.appendChild(surface);
fireEvent.pointerDown(surface);
expect(onClose).not.toHaveBeenCalled();
surface.remove();
unmount();
}
});
it("does not close when pointerdown targets Quick Chat's body-portaled dropdown surfaces", () => {
for (const portalClassName of QUICK_CHAT_PORTALED_MENU_CLASSES) {
const onClose = vi.fn();
const { unmount } = render(
<FloatingWindow windowKey={`portal-safe-${portalClassName}`} title="Portal safe" onClose={onClose} closeOnOutsidePointerDown>
<div>chat body</div>
</FloatingWindow>
);
const portalSurface = document.createElement("div");
portalSurface.className = portalClassName;
document.body.appendChild(portalSurface);
fireEvent.pointerDown(portalSurface);
expect(onClose).not.toHaveBeenCalled();
portalSurface.remove();
unmount();
}
});
it("does not close when pointerdown targets an element inside a Quick Chat body-portaled dropdown", () => {
const onClose = vi.fn();
render(
<FloatingWindow windowKey="portal-child-safe" title="Portal child safe" onClose={onClose} closeOnOutsidePointerDown>
<div>chat body</div>
</FloatingWindow>
);
const portalSurface = document.createElement("div");
portalSurface.className = "model-combobox-dropdown--portal";
const option = document.createElement("button");
option.type = "button";
option.textContent = "Model option";
portalSurface.appendChild(option);
document.body.appendChild(portalSurface);
fireEvent.pointerDown(option);
expect(onClose).not.toHaveBeenCalled();
portalSurface.remove();
});
it("does not close from outside pointerdown while a resize gesture is active", () => {
const onClose = vi.fn();
render(
<FloatingWindow windowKey="resize-safe" title="Resize safe" onClose={onClose} closeOnOutsidePointerDown>
<div>resize body</div>
</FloatingWindow>
);
fireEvent.pointerDown(screen.getByTestId("floating-window-resize-se"), { pointerId: 1 });
fireEvent.pointerDown(document.body);
expect(onClose).not.toHaveBeenCalled();
});
it("ignores compatibility pointer events immediately after touch gestures", () => {
const onClose = vi.fn();
render(
<FloatingWindow windowKey="touch-safe" title="Touch safe" onClose={onClose} closeOnOutsidePointerDown>
<div>touch body</div>
</FloatingWindow>
);
expect(onClose).not.toHaveBeenCalled();
fireEvent.touchStart(document);
fireEvent.touchEnd(document);
fireEvent.pointerDown(document.body);
expect(onClose).not.toHaveBeenCalled();
});
it("removes the outside pointerdown listener on unmount", () => {
const onClose = vi.fn();
const { unmount } = render(
<FloatingWindow windowKey="cleanup" title="Cleanup" onClose={onClose} closeOnOutsidePointerDown>
<div>cleanup body</div>
</FloatingWindow>
);
unmount();
fireEvent.pointerDown(document.body);
expect(onClose).not.toHaveBeenCalled();
});
it("multiple windows coexist independently (each renders its own panel)", () => {
render(
<>
<FloatingWindow windowKey="w1" title="W1" onClose={() => {}}>
<div>one</div>
</FloatingWindow>
<FloatingWindow windowKey="w2" title="W2" onClose={() => {}}>
<div>two</div>
</FloatingWindow>
<FloatingWindow windowKey="w3" title="W3" onClose={() => {}}>
<div>three</div>
</FloatingWindow>
</>
);
expect(screen.getByTestId("floating-window-w1")).toBeTruthy();
expect(screen.getByTestId("floating-window-w2")).toBeTruthy();
expect(screen.getByTestId("floating-window-w3")).toBeTruthy();
});
it("restores persisted geometry and clamps it on screen", () => {
localStorage.setItem(
"floating-window:test",
JSON.stringify({
size: { width: 700, height: 500 },
position: { x: 9999, y: -200 },
}),
);
render(
<FloatingWindow
windowKey="persisted"
title="Persisted"
onClose={() => {}}
persistGeometryKey="floating-window:test"
minSize={{ width: 360, height: 280 }}
>
<div>persisted body</div>
</FloatingWindow>
);
const panel = screen.getByTestId("floating-window-persisted");
expect(panel.style.width).toBe("700px");
expect(panel.style.height).toBe("500px");
expect(panel.style.top).toBe("16px");
expect(Number.parseFloat(panel.style.left)).toBeLessThan(window.innerWidth);
});
it("reloads persisted geometry when a mounted host changes its project-scoped identity", () => {
const firstKey = "floating-window:project-one";
const secondKey = "floating-window:project-two";
const firstGeometry = { size: { width: 610, height: 430 }, position: { x: 80, y: 90 } };
const secondGeometry = { size: { width: 700, height: 500 }, position: { x: 120, y: 110 } };
localStorage.setItem(firstKey, JSON.stringify(firstGeometry));
localStorage.setItem(secondKey, JSON.stringify(secondGeometry));
const { rerender } = render(
<FloatingWindow windowKey="terminal-project-one" title="Terminal" onClose={() => {}} persistGeometryKey={firstKey}>
<div>terminal body</div>
</FloatingWindow>,
);
expect(screen.getByTestId("floating-window-terminal-project-one")).toHaveStyle({ width: "610px", height: "430px" });
rerender(
<FloatingWindow windowKey="terminal-project-two" title="Terminal" onClose={() => {}} persistGeometryKey={secondKey}>
<div>terminal body</div>
</FloatingWindow>,
);
expect(screen.getByTestId("floating-window-terminal-project-two")).toHaveStyle({ width: "700px", height: "500px" });
expect(JSON.parse(localStorage.getItem(firstKey) ?? "{}")).toEqual(firstGeometry);
});
it("falls back to default geometry when persisted geometry is malformed", () => {
localStorage.setItem("floating-window:malformed", "not-json");
render(
<FloatingWindow
windowKey="malformed"
title="Malformed"
onClose={() => {}}
persistGeometryKey="floating-window:malformed"
defaultSize={{ width: 610, height: 430 }}
defaultPosition={{ x: 80, y: 90 }}
>
<div>malformed body</div>
</FloatingWindow>
);
const panel = screen.getByTestId("floating-window-malformed");
expect(panel.style.width).toBe("610px");
expect(panel.style.height).toBe("430px");
expect(panel.style.left).toBe("80px");
expect(panel.style.top).toBe("90px");
});
it("preserves desktop geometry during opt-in sheet opens and restores it on desktop", () => {
const key = "floating-window:sheet-preserve";
const desktopGeometry = { size: { width: 640, height: 460 }, position: { x: 120, y: 96 } };
localStorage.setItem(key, JSON.stringify(desktopGeometry));
setSheetViewport(true);
const { unmount } = render(
<FloatingWindow
windowKey="sheet-preserve-mobile"
title="Sheet"
onClose={() => {}}
persistGeometryKey={key}
suspendGeometryPersistenceOnMobile
defaultSize={{ width: 500, height: 400 }}
defaultPosition={{ x: 32, y: 48 }}
>
<div>sheet body</div>
</FloatingWindow>,
);
const sheetPanel = screen.getByTestId("floating-window-sheet-preserve-mobile");
expect(sheetPanel.style.width).toBe("500px");
expect(sheetPanel.style.left).toBe("32px");
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual(desktopGeometry);
unmount();
setSheetViewport(false);
render(
<FloatingWindow windowKey="sheet-preserve-desktop" title="Desktop" onClose={() => {}} persistGeometryKey={key} suspendGeometryPersistenceOnMobile>
<div>desktop body</div>
</FloatingWindow>,
);
const desktopPanel = screen.getByTestId("floating-window-sheet-preserve-desktop");
expect(desktopPanel.style.width).toBe("640px");
expect(desktopPanel.style.height).toBe("460px");
expect(desktopPanel.style.left).toBe("120px");
expect(desktopPanel.style.top).toBe("96px");
});
it("preserves opt-in geometry during a short-viewport full-screen sheet", () => {
const key = "floating-window:short-sheet";
const desktopGeometry = { size: { width: 640, height: 460 }, position: { x: 120, y: 96 } };
localStorage.setItem(key, JSON.stringify(desktopGeometry));
setSheetViewport(false);
render(
<FloatingWindow
windowKey="short-sheet"
title="Short sheet"
onClose={() => {}}
persistGeometryKey={key}
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
defaultSize={{ width: 500, height: 400 }}
defaultPosition={{ x: 32, y: 48 }}
>
<div>short sheet body</div>
</FloatingWindow>,
);
const sheetPanel = screen.getByTestId("floating-window-short-sheet");
expect(sheetPanel.style.width).toBe("500px");
expect(sheetPanel.style.left).toBe("32px");
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual(desktopGeometry);
});
it("keeps opt-in geometry persistence on wide short landscape phones that remain movable", () => {
const key = "floating-window:landscape-phone";
localStorage.setItem(key, JSON.stringify({ size: { width: 620, height: 450 }, position: { x: 100, y: 80 } }));
// `isMobileViewport()` would be true for this max-height match, but sheets use only max-width.
setSheetViewport(false);
render(
<FloatingWindow windowKey="landscape-phone" title="Landscape" onClose={() => {}} persistGeometryKey={key} suspendGeometryPersistenceOnMobile>
<div>landscape body</div>
</FloatingWindow>,
);
const panel = screen.getByTestId("floating-window-landscape-phone");
expect(panel.style.width).toBe("620px");
expect(panel.style.left).toBe("100px");
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual({ size: { width: 620, height: 450 }, position: { x: 100, y: 80 } });
});
it("suppresses header drag and persistence in an opt-in short sheet", () => {
const key = "floating-window:short-sheet-gesture";
setSheetViewport(false);
render(
<FloatingWindow
windowKey="short-sheet-gesture"
title="Short sheet"
onClose={() => {}}
persistGeometryKey={key}
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
defaultPosition={{ x: 80, y: 90 }}
>
<div>short sheet body</div>
</FloatingWindow>,
);
const panel = screen.getByTestId("floating-window-short-sheet-gesture");
const header = screen.getByTestId("floating-window-drag-handle-short-sheet-gesture");
fireEvent.pointerDown(header, { pointerId: 91, pointerType: "touch", clientX: 100, clientY: 100 });
fireEvent.pointerMove(header, { pointerId: 91, pointerType: "touch", clientX: 160, clientY: 150 });
fireEvent.pointerUp(header, { pointerId: 91, pointerType: "touch", clientX: 160, clientY: 150 });
expect(panel.style.left).toBe("80px");
expect(panel.style.top).toBe("90px");
expect(localStorage.getItem(key)).toBeNull();
});
it("continues persistence at sheet width when suspension is not opted in", () => {
const key = "floating-window:sheet-default";
const geometry = { size: { width: 610, height: 440 }, position: { x: 90, y: 72 } };
localStorage.setItem(key, JSON.stringify(geometry));
setSheetViewport(true);
render(
<FloatingWindow windowKey="sheet-default" title="Default" onClose={() => {}} persistGeometryKey={key}>
<div>default body</div>
</FloatingWindow>,
);
const panel = screen.getByTestId("floating-window-sheet-default");
expect(panel.style.width).toBe("610px");
expect(panel.style.left).toBe("90px");
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual(geometry);
});
it("shares geometry only between windows that opt into the same persistence key", () => {
localStorage.setItem(
"floating-window:shared-task-detail",
JSON.stringify({
size: { width: 660, height: 470 },
position: { x: 120, y: 96 },
}),
);
localStorage.setItem(
"floating-window:chat",
JSON.stringify({
size: { width: 520, height: 390 },
position: { x: 220, y: 140 },
}),
);
render(
<>
<FloatingWindow
windowKey="task-detail-FN-001"
title="FN-001"
onClose={() => {}}
persistGeometryKey="floating-window:shared-task-detail"
>
<div>task one</div>
</FloatingWindow>
<FloatingWindow
windowKey="task-detail-FN-002"
title="FN-002"
onClose={() => {}}
persistGeometryKey="floating-window:shared-task-detail"
>
<div>task two</div>
</FloatingWindow>
<FloatingWindow windowKey="chat" title="Chat" onClose={() => {}} persistGeometryKey="floating-window:chat">
<div>chat body</div>
</FloatingWindow>
</>
);
for (const id of ["FN-001", "FN-002"]) {
const panel = screen.getByTestId(`floating-window-task-detail-${id}`);
expect(panel.style.width).toBe("660px");
expect(panel.style.height).toBe("470px");
expect(panel.style.left).toBe("120px");
expect(panel.style.top).toBe("96px");
}
const chatPanel = screen.getByTestId("floating-window-chat");
expect(chatPanel.style.width).toBe("520px");
expect(chatPanel.style.height).toBe("390px");
expect(chatPanel.style.left).toBe("220px");
expect(chatPanel.style.top).toBe("140px");
});
it("keeps hidden children mounted while suspending invisible-window effects and reclaiming the task-detail stack", () => {
const onClose = vi.fn();
const geometryEvents = vi.fn();
const storageKey = "floating-window:hidden";
window.addEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, geometryEvents);
const { rerender } = render(
<>
<FloatingWindow
windowKey="hidden-chat"
title="Chat"
onClose={onClose}
hidden
closeOnOutsidePointerDown
persistGeometryKey={storageKey}
layer="task-detail"
>
<div data-testid="retained-hidden-child">retained chat</div>
</FloatingWindow>
<FloatingWindow windowKey="active-task" title="Task" onClose={() => {}} layer="task-detail">
<div>active task</div>
</FloatingWindow>
</>,
);
const hiddenOverlay = screen.getByTestId("floating-window-overlay-hidden-chat");
const retainedChild = screen.getByTestId("retained-hidden-child");
const activeTask = screen.getByTestId("floating-window-active-task");
const hiddenRule = cssRuleFor(floatingWindowCss, ".floating-window-overlay--hidden");
expect(hiddenOverlay).toHaveClass("floating-window-overlay--hidden");
expect(hiddenOverlay).toHaveAttribute("aria-hidden", "true");
expect(hiddenRule).toContain("visibility: hidden;");
expect(hiddenRule).toContain("pointer-events: none;");
expect(hiddenRule).not.toMatch(/display\s*:\s*none/);
expect(geometryEvents).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(storageKey)).toBeNull();
fireEvent.pointerDown(document.body);
expect(onClose).not.toHaveBeenCalled();
rerender(
<>
<FloatingWindow
windowKey="hidden-chat"
title="Chat"
onClose={onClose}
closeOnOutsidePointerDown
persistGeometryKey={storageKey}
layer="task-detail"
>
<div data-testid="retained-hidden-child">retained chat</div>
</FloatingWindow>
<FloatingWindow windowKey="active-task" title="Task" onClose={() => {}} layer="task-detail">
<div>active task</div>
</FloatingWindow>
</>,
);
const visibleOverlay = screen.getByTestId("floating-window-overlay-hidden-chat");
const shownChat = screen.getByTestId("floating-window-hidden-chat");
expect(visibleOverlay).not.toHaveClass("floating-window-overlay--hidden");
expect(visibleOverlay).not.toHaveAttribute("aria-hidden");
expect(screen.getByTestId("retained-hidden-child")).toBe(retainedChild);
expect(geometryEvents).toHaveBeenCalledTimes(2);
expect(localStorage.getItem(storageKey)).not.toBeNull();
expect(Number(shownChat.style.zIndex)).toBeGreaterThan(Number(activeTask.style.zIndex));
fireEvent.pointerDown(document.body);
expect(onClose).toHaveBeenCalledTimes(1);
window.removeEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, geometryEvents);
});
it("keeps the hidden prop opt-in so existing callers retain visible, interactive behavior", () => {
const onClose = vi.fn();
const geometryEvents = vi.fn();
const storageKey = "floating-window:default-hidden-off";
window.addEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, geometryEvents);
render(
<FloatingWindow windowKey="default-hidden-off" title="Visible by default" onClose={onClose} closeOnOutsidePointerDown persistGeometryKey={storageKey}>
<div>existing caller body</div>
</FloatingWindow>,
);
const overlay = screen.getByTestId("floating-window-overlay-default-hidden-off");
expect(overlay).not.toHaveClass("floating-window-overlay--hidden");
expect(overlay).not.toHaveAttribute("aria-hidden");
expect(geometryEvents).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(storageKey)).not.toBeNull();
fireEvent.pointerDown(document.body);
expect(onClose).toHaveBeenCalledTimes(1);
window.removeEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, geometryEvents);
});
/*
FNXC:ModalTouchGeometry 2026-07-26-14:15:
FN-8606 has thirteen modal identities but one geometry owner. Exercise every production class/key
through the shared primitive so touch drag/resize, corrupt/off-screen restoration, persistence,
and both sheet suspension breakpoints cannot silently diverge by caller identity.
*/
it.each(FN_8606_WINDOW_IDENTITIES)("keeps %s touch-moveable, resizable, clamped, and persisted", (_component, windowKey) => {
const geometryKey = `floating-window:${windowKey}`;
localStorage.setItem(geometryKey, JSON.stringify({
size: { width: 99999, height: 99999 },
position: { x: 99999, y: -99999 },
}));
const { unmount } = render(
<FloatingWindow
windowKey={windowKey}
title={windowKey}
ariaLabel={`${windowKey} dialog`}
onClose={() => {}}
hideHeader
dragHandleSelector=".migration-drag-handle"
className={`floating-window--${windowKey}`}
defaultSize={{ width: 500, height: 400 }}
minSize={{ width: 360, height: 280 }}
persistGeometryKey={geometryKey}
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
>
<div className="migration-drag-handle">Drag {windowKey}</div>
</FloatingWindow>,
);
const panel = expectFloatingWindowStructure(windowKey);
expect(screen.getByTestId(`floating-window-overlay-${windowKey}`)).toHaveAttribute("aria-label", `${windowKey} dialog`);
expect(Number.parseInt(panel.style.left, 10)).toBeGreaterThanOrEqual(16);
expect(Number.parseInt(panel.style.top, 10)).toBeGreaterThanOrEqual(16);
dragWithTouch(screen.getByText(`Drag ${windowKey}`));
resizeWithTouch(screen.getByTestId("floating-window-resize-se"));
const persisted = JSON.parse(localStorage.getItem(geometryKey) ?? "{}");
expect(persisted.position.x).toBeGreaterThanOrEqual(16);
expect(persisted.position.y).toBeGreaterThanOrEqual(16);
expect(persisted.size.width).toBeLessThanOrEqual(window.innerWidth - 32);
expect(persisted.size.height).toBeLessThanOrEqual(window.innerHeight - 32);
unmount();
});
it.each(FN_8606_WINDOW_IDENTITIES)("rejects corrupt persisted geometry for %s", (_component, windowKey) => {
const geometryKey = `floating-window:${windowKey}`;
localStorage.setItem(geometryKey, "not-json");
render(
<FloatingWindow
windowKey={windowKey}
title={windowKey}
onClose={() => {}}
defaultSize={{ width: 500, height: 400 }}
persistGeometryKey={geometryKey}
>
<div>corrupt geometry fallback</div>
</FloatingWindow>,
);
const panel = screen.getByTestId(`floating-window-${windowKey}`);
expect(Number.parseInt(panel.style.width, 10)).toBe(500);
});
it.each(FN_8606_WINDOW_IDENTITIES)("wires %s to its accessible shared-window identity", (component, windowKey) => {
const source = readAppFile(`components/${component}`);
expect(source).toContain(`windowKey=\"${windowKey}\"`);
expect(source).toContain(`className=\"floating-window--${windowKey}\"`);
expect(source).toContain(`persistGeometryKey=\"floating-window:${windowKey}\"`);
expect(source).toContain("ariaLabel=");
expect(source).toContain("suspendGeometryPersistenceOnMobile");
expect(source).toContain("suspendGeometryPersistenceOnShortViewport");
});
it.each(["phone", "short viewport"] as const)("suspends all FN-8606 geometry keys in %s sheet mode", (mode) => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: mode === "phone" ? query === "(max-width: 767.98px)" : query === "(max-height: 480px)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
for (const [, windowKey] of FN_8606_WINDOW_IDENTITIES) {
const geometryKey = `floating-window:${windowKey}`;
const { unmount } = render(
<FloatingWindow
windowKey={windowKey}
title={windowKey}
ariaLabel={`${windowKey} dialog`}
onClose={() => {}}
hideHeader
dragHandleSelector=".migration-drag-handle"
className={`floating-window--${windowKey}`}
persistGeometryKey={geometryKey}
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
>
<div className="migration-drag-handle">Drag {windowKey}</div>
</FloatingWindow>,
);
expect(localStorage.getItem(geometryKey)).toBeNull();
expect(screen.getByTestId(`floating-window-${windowKey}`)).toBeInTheDocument();
expect(screen.queryByTestId("floating-window-resize-se")).not.toBeInTheDocument();
unmount();
}
const sheetBlock = mediaBlockFor(floatingWindowCss, "(max-width: 767.98px), (max-height: 480px)");
for (const [, windowKey] of FN_8606_WINDOW_IDENTITIES) {
expect(sheetBlock).toContain(`.floating-window--${windowKey}`);
}
});
it("makes only the mobile chat floating window full-screen", () => {
const mobileBlock = floatingWindowCss.match(/@media\s*\(max-width:\s*767\.98px\),\s*\(max-height:\s*480px\)\s*\{[\s\S]*?\.floating-window--chat \.chat-view\s*\{[\s\S]*?\n\}/)?.[0];
expect(mobileBlock).toContain(".floating-window--chat");
expect(mobileBlock).toContain("width: 100vw !important;");
expect(mobileBlock).toContain("height: 100dvh !important;");
expect(mobileBlock).toContain(".floating-window--chat .floating-window__resize-handle");
expect(mediaBlockFor(floatingWindowCss, "(min-width: 769px) and (max-width: 1024px)")).not.toContain(".floating-window--chat");
});
});