mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
8e88dcec8c
* 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
158 lines
5.0 KiB
TypeScript
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();
|
|
});
|
|
});
|