Files
backnotprop__plannotator/packages/ui/components/CommentPopover.forcedDialog.test.tsx
Michael Ramos 8e88dcec8c fix: v0.27.2 pre-release QA batch (mobile TOC, dialog bounds, seed guard) (#1311)
* fix(plan): make the compact TOC scroll the document again

The compact navigator overlay rendered outside App's ScrollViewportProvider,
so the TableOfContents it hosts resolved a null viewport and every "jump to
heading" tap was a silent no-op on phones. The provider is context-only, so
hoisting it above the overlay fixes the lookup without touching desktop DOM
structure or order.

* fix(plan): scope the permission-mode chooser to plan review and bound its card

The one-time chooser fired in every non-goal-setup Claude Code session, so
annotate, annotate-last, annotate-folder and archive reviewers got a blocking
dialog about what happens after plan approval. Gate it on plan review, which
is the absence of a mode field in the /api/plan payload.

The card itself was hand-rolled with no height cap and no internal scroll, so
on a short landscape phone it overflowed both edges of a modal that has no
dismiss control. Give it the same bounded shell the sibling one-time dialogs
use: safe-area padding, a visible-viewport max height, and the option list as
the only scrolling region. Content and cookie behavior are unchanged.

* fix(review): never seed Tree over a persisted panel view

The first-run initializer gated only on the setup-seen cookie, but sessions
that never reach it (non-git, workspace, PR, no since-base) still let Settings
persist a panel view. A reviewer could hold an explicit Git status choice with
"seen" unset, and the next plain git session seeded Tree over it. Treat a
persisted view as the decision: consume the one-time setup and write nothing.

* fix(comments): give the geometry-forced composer a working Escape

When the anchor has no room the position tracker forces dialog mode. On a
fine-pointer viewport Escape took the collapse branch, the tracker instantly
re-forced the dialog, and the keystroke was eaten; the Collapse button bounced
the same way. Track forced expansion separately from the preferred kind: in
that state Escape closes (draft-preserving) and Collapse is hidden, because
collapsing is geometrically impossible.

* fix(review): stop the compact Editor tab editing the desktop diff style

The dock's Split/Unified control returns null under the compact touch layout,
but the Settings copy of it kept rendering while the phone showed the
session-only unified diff. It looked dead and silently rewrote the persisted
desktop preference. Hide it on compact and state what the session is doing;
the prop defaults to false, so the plan editor and desktop are untouched.

* fix(portal): give the share portal the mobile app shell

The portal mounts the same plan editor App as the hook but kept the pre-mobile
entry document: no viewport-fit=cover (so every safe-area token was inert) and
a min-h-screen body without the shell's scroll ownership. Mirror the hook's
body class, root class, and viewport meta, and extend the entry-asset pin to
cover the portal alongside them.

* fix(plan): keep compact overlays out of the printed document

The compact plan stage and the compact navigator are full-viewport transient
surfaces with no print-hide marker, so printing on a touch device with
Annotations, Ask AI, Versions or Archive open clipped the document behind
them. Mark both with data-print-hide, which print.css already hides. The
desktop rail is untouched.

* fix(plan): give the selection toolbar real touch targets

Copy / Delete / Comment / quick label / looks-good / Cancel measured 28x28
with 2px gaps on a phone because the toolbar never got the touch-target
markers the rest of the stack uses. Stamp them on its buttons and add a
compact-scoped gap so adjacent destructive and comment actions are not a
mis-tap apart. Both are inert outside the compact scope, so desktop geometry
is unchanged.

* docs: keep the new QA-batch comments free of em dashes
2026-08-13 11:35:00 -07:00

158 lines
5.0 KiB
TypeScript

/**
* Geometry-forced expanded composer (DOM_TESTS=1)
*
* On a fine-pointer viewport the composer normally opens as a popover and the
* expanded dialog is a user choice, so Escape and the Collapse button take it
* back. When the anchor has no room, the position tracker FORCES the dialog,
* and collapsing recomputes the same geometry and immediately re-expands. That
* made Escape a no-op and left the composer with no keyboard exit.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { CommentPopover } from './CommentPopover';
const hasDom = typeof document !== 'undefined';
// Fine pointer, and a viewport short enough that a mid-document anchor has
// under 280px of room on either side (the popover's minimum).
const VIEWPORT_WIDTH = 900;
const VIEWPORT_HEIGHT = 560;
const CRAMPED_ANCHOR = { top: 280, bottom: 300, left: 400, right: 460, width: 60 };
const ROOMY_ANCHOR = { top: 40, bottom: 60, left: 400, right: 460, width: 60 };
let root: Root | null = null;
let host: HTMLElement | null = null;
let originalMatchMedia: typeof window.matchMedia | undefined;
let originalWidth = 0;
let originalHeight = 0;
// SAFETY: implements the MediaQueryList surface the composer consumes; nothing
// matches, which is a fine-pointer desktop.
function fineMatchMedia(query: string): MediaQueryList {
return {
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => true,
} as unknown as MediaQueryList;
}
function setViewport(width: number, height: number): void {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: height });
}
async function mount(ui: React.ReactElement): Promise<void> {
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
await act(async () => root?.render(ui));
await act(async () => new Promise(resolve => setTimeout(resolve, 0)));
}
function dialog(): HTMLElement | null {
return document.querySelector<HTMLElement>('[role="dialog"]');
}
function collapseButton(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>('button[title="Collapse"]');
}
async function pressEscapeInComposer(): Promise<void> {
const textarea = document.querySelector<HTMLTextAreaElement>('textarea');
if (!textarea) throw new Error('composer textarea did not render');
await act(async () => {
textarea.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}));
});
}
beforeEach(() => {
if (!hasDom) return;
originalMatchMedia = window.matchMedia;
originalWidth = window.innerWidth;
originalHeight = window.innerHeight;
window.matchMedia = fineMatchMedia as typeof window.matchMedia;
setViewport(VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
});
afterEach(async () => {
if (root) await act(async () => root?.unmount());
root = null;
host?.remove();
host = null;
if (hasDom) {
document.body.replaceChildren();
if (originalMatchMedia) window.matchMedia = originalMatchMedia;
setViewport(originalWidth, originalHeight);
}
});
describe.if(hasDom)('CommentPopover forced expansion', () => {
test('a cramped anchor opens the dialog with no Collapse control', async () => {
await mount(
<CommentPopover
anchorRect={CRAMPED_ANCHOR as DOMRect}
contextText="selected text"
isGlobal={false}
onSubmit={() => {}}
onClose={() => {}}
/>,
);
expect(dialog()).not.toBeNull();
expect(collapseButton()).toBeNull();
});
test('Escape closes a forced dialog instead of bouncing off the re-expand', async () => {
let closes = 0;
await mount(
<CommentPopover
anchorRect={CRAMPED_ANCHOR as DOMRect}
contextText="selected text"
isGlobal={false}
onSubmit={() => {}}
onClose={() => { closes += 1; }}
/>,
);
expect(dialog()).not.toBeNull();
await pressEscapeInComposer();
expect(closes).toBe(1);
});
test('a user-expanded dialog still collapses on Escape and keeps its Collapse control', async () => {
let closes = 0;
await mount(
<CommentPopover
anchorRect={ROOMY_ANCHOR as DOMRect}
contextText="selected text"
isGlobal={false}
onSubmit={() => {}}
onClose={() => { closes += 1; }}
/>,
);
// Roomy anchor: opens as a popover.
expect(dialog()).toBeNull();
const expand = document.querySelector<HTMLButtonElement>('button[title="Expand"]');
if (!expand) throw new Error('popover did not render its Expand control');
await act(async () => expand.click());
expect(dialog()).not.toBeNull();
expect(collapseButton()).not.toBeNull();
await pressEscapeInComposer();
expect(closes).toBe(0);
expect(dialog()).toBeNull();
});
});