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>
1308 lines
57 KiB
TypeScript
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");
|
|
});
|
|
});
|