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
168 lines
5.8 KiB
TypeScript
168 lines
5.8 KiB
TypeScript
/**
|
|
* Compact navigator "jump to heading" (DOM_TESTS=1)
|
|
*
|
|
* The compact navigator renders the SAME TableOfContents as the desktop rail,
|
|
* and that component resolves its scroll target from ScrollViewportContext.
|
|
* When the overlay rendered outside App's ScrollViewportProvider the context
|
|
* was null, so every heading tap silently did nothing. This mounts the real
|
|
* app in the compact touch shell and pins that a TOC activation still scrolls
|
|
* the viewport.
|
|
*/
|
|
|
|
import { afterAll, afterEach, describe, expect, test } from "bun:test";
|
|
import React, { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import {
|
|
resetStorageBackend,
|
|
setStorageBackend,
|
|
type StorageBackend,
|
|
} from "@plannotator/ui/utils/storage";
|
|
|
|
const hasDom = typeof document !== "undefined";
|
|
|
|
const appModule = hasDom ? await import("./App") : null;
|
|
const App = appModule?.default as typeof import("./App")["default"];
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const originalEventSource = globalThis.EventSource;
|
|
const originalMatchMedia = hasDom ? window.matchMedia : undefined;
|
|
const originalScrollTo = hasDom ? window.scrollTo : undefined;
|
|
|
|
const PLAN = "# Alpha section\n\nFirst body paragraph.\n\n## Beta section\n\nSecond body paragraph.\n";
|
|
|
|
const memory = new Map<string, string>();
|
|
const memoryBackend: StorageBackend = {
|
|
getItem: (key) => memory.get(key) ?? null,
|
|
setItem: (key, value) => void memory.set(key, value),
|
|
removeItem: (key) => void memory.delete(key),
|
|
};
|
|
|
|
function seedAnnouncementsSeen(): void {
|
|
memory.set("plannotator-look-feel-announcement-seen", "2");
|
|
memory.set("plannotator-vim-mode-announcement-seen", "2");
|
|
memory.set("plannotator-plan-ai-announcement-seen", "1");
|
|
}
|
|
|
|
// SAFETY: implements the MediaQueryList surface the shell hooks consume.
|
|
// Coarse-pointer matches put the app in its compact touch layout.
|
|
function coarseMatchMedia(query: string): MediaQueryList {
|
|
return {
|
|
matches: query.includes("pointer: coarse"),
|
|
media: query,
|
|
onchange: null,
|
|
addListener: () => {},
|
|
removeListener: () => {},
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
dispatchEvent: () => true,
|
|
} as unknown as MediaQueryList;
|
|
}
|
|
|
|
class SilentEventSource {
|
|
static readonly CONNECTING = 0;
|
|
static readonly OPEN = 1;
|
|
static readonly CLOSED = 2;
|
|
|
|
readonly CONNECTING = 0;
|
|
readonly OPEN = 1;
|
|
readonly CLOSED = 2;
|
|
readonly readyState = SilentEventSource.OPEN;
|
|
readonly url: string;
|
|
readonly withCredentials = false;
|
|
onerror: ((event: Event) => void) | null = null;
|
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
onopen: ((event: Event) => void) | null = null;
|
|
|
|
constructor(url: string | URL) {
|
|
this.url = String(url);
|
|
}
|
|
|
|
addEventListener(): void {}
|
|
close(): void {}
|
|
dispatchEvent(): boolean { return true; }
|
|
removeEventListener(): void {}
|
|
}
|
|
|
|
const planFetch: typeof fetch = async (input) => {
|
|
const rawUrl = input instanceof Request ? input.url : String(input);
|
|
if (rawUrl.startsWith("https://api.github.com/")) return new Response(null, { status: 404 });
|
|
const url = new URL(rawUrl, "http://localhost");
|
|
if (url.pathname === "/api/plan") {
|
|
return Response.json({ plan: PLAN, origin: "codex", sharingEnabled: false, serverConfig: {} });
|
|
}
|
|
if (url.pathname === "/api/ai/capabilities") return Response.json({ available: false, providers: [] });
|
|
if (url.pathname === "/api/draft") return Response.json({ error: "Not found" }, { status: 404 });
|
|
return Response.json({});
|
|
};
|
|
|
|
let root: Root | null = null;
|
|
let host: HTMLElement | null = null;
|
|
|
|
async function settle(): Promise<void> {
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
afterEach(async () => {
|
|
if (root) await act(async () => root?.unmount());
|
|
root = null;
|
|
host?.remove();
|
|
host = null;
|
|
globalThis.fetch = originalFetch;
|
|
globalThis.EventSource = originalEventSource;
|
|
if (hasDom) {
|
|
if (originalMatchMedia) window.matchMedia = originalMatchMedia;
|
|
if (originalScrollTo) window.scrollTo = originalScrollTo;
|
|
document.body.replaceChildren();
|
|
}
|
|
memory.clear();
|
|
resetStorageBackend();
|
|
});
|
|
|
|
afterAll(() => {
|
|
resetStorageBackend();
|
|
});
|
|
|
|
describe.if(hasDom)("compact navigator table of contents", () => {
|
|
test("a heading activation scrolls the document viewport", async () => {
|
|
setStorageBackend(memoryBackend);
|
|
seedAnnouncementsSeen();
|
|
window.matchMedia = coarseMatchMedia as typeof window.matchMedia;
|
|
globalThis.fetch = planFetch;
|
|
// SAFETY: the App only uses EventSource's constructor, handlers, and close.
|
|
globalThis.EventSource = SilentEventSource as unknown as typeof EventSource;
|
|
|
|
host = document.createElement("div");
|
|
document.body.appendChild(host);
|
|
root = createRoot(host);
|
|
await act(async () => { root?.render(<App />); });
|
|
|
|
let trigger: HTMLElement | null = null;
|
|
for (let attempt = 0; attempt < 20 && !trigger; attempt += 1) {
|
|
await settle();
|
|
trigger = document.getElementById("pn-compact-plan-navigator-trigger");
|
|
}
|
|
if (!trigger) throw new Error("compact navigator trigger never rendered");
|
|
|
|
await act(async () => trigger?.click());
|
|
await settle();
|
|
|
|
const tocButton = Array.from(
|
|
document.querySelectorAll<HTMLButtonElement>('nav[aria-label="Table of contents"] button'),
|
|
).find((button) => button.textContent?.includes("Beta section"));
|
|
if (!tocButton) throw new Error("compact navigator did not render the plan's headings");
|
|
|
|
// The heading has to exist in the document under the overlay, otherwise the
|
|
// no-op being guarded against would be indistinguishable from a miss.
|
|
expect(document.querySelector("[data-block-id]")).not.toBeNull();
|
|
|
|
const scrollCalls: unknown[] = [];
|
|
window.scrollTo = ((...args: unknown[]) => { scrollCalls.push(args); }) as typeof window.scrollTo;
|
|
|
|
await act(async () => tocButton.click());
|
|
|
|
expect(scrollCalls.length).toBeGreaterThan(0);
|
|
});
|
|
});
|