import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { cssDeclarations } from "./css-contract-test-support.ts"; import { installViewportScrollLock, isKeyboardOpen, shouldResetWindowScroll, type ViewportScrollLockHost, } from "../src/lib/viewport-scroll-lock.ts"; const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8"); const component = readFileSync(new URL("../src/components/viewport-scroll-lock.tsx", import.meta.url), "utf8"); const lock = readFileSync(new URL("../src/lib/viewport-scroll-lock.ts", import.meta.url), "utf8"); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); test("ViewportScrollLock mounts beside StaleClientRecovery and uses the shared installer", () => { assert.match(layout, /import \{ ViewportScrollLock \} from "@\/components\/viewport-scroll-lock"/); assert.match(layout, /\s*/); assert.match(component, /installViewportScrollLock/); assert.match(lock, /history\.scrollRestoration = "manual"/); assert.match(lock, /addEventListener\("resize"/); assert.match(lock, /addEventListener\("scroll"/); assert.match(lock, /addEventListener\("pageshow"/); assert.match(lock, /addEventListener\("orientationchange"/); assert.match(lock, /addEventListener\("focusout"/); assert.match(lock, /KEYBOARD_OPEN_GAP_PX = 100/); assert.doesNotMatch(lock, /setInterval/); }); test("keyboard-open window offsets are left alone", () => { assert.equal(isKeyboardOpen(680, 800), true); assert.equal(isKeyboardOpen(799, 800), false); assert.equal(shouldResetWindowScroll({ scrollY: 260, innerHeight: 800, visualViewportHeight: 680, }), false); assert.equal(shouldResetWindowScroll({ scrollY: 260, innerHeight: 800, visualViewportHeight: 800, }), true); assert.equal(shouldResetWindowScroll({ scrollY: 0, innerHeight: 800, visualViewportHeight: 800, }), false); }); test("visualViewport resize resets window scroll once the keyboard has closed", () => { const calls: Array<[number, number]> = []; const viewportListeners = new Map void>(); const host: ViewportScrollLockHost = { innerHeight: 800, scrollY: 260, scrollTo(x, y) { host.scrollY = y; calls.push([x, y]); }, history: { scrollRestoration: "auto" }, visualViewport: { height: 800, addEventListener(type, listener) { viewportListeners.set(type, listener); }, removeEventListener(type) { viewportListeners.delete(type); }, }, matchMedia: () => ({ matches: true }), addEventListener() {}, removeEventListener() {}, document: { addEventListener() {}, removeEventListener() {}, }, }; const uninstall = installViewportScrollLock(host); assert.equal(host.history.scrollRestoration, "manual"); assert.deepEqual(calls, [[0, 0]]); host.scrollY = 180; host.visualViewport!.height = 680; viewportListeners.get("resize")?.(); assert.equal(calls.length, 1); host.visualViewport!.height = 800; viewportListeners.get("resize")?.(); assert.deepEqual(calls.at(-1), [0, 0]); assert.equal(calls.length, 2); uninstall(); }); test("header chips share height, type size, and radius, and drop the 64px min-width", () => { const shared = cssDeclarations( ".chat-header-actions .credit-button,\n.chat-header-rectification .rectification-board-peek", styles, ); assert.match(shared, /height:\s*40px/); assert.match(shared, /min-height:\s*40px/); assert.match(shared, /font-size:\s*var\(--type-body-sm\)/); assert.match(shared, /border-radius:\s*var\(--radius-md\)/); assert.match(shared, /font-variant-numeric:\s*tabular-nums/); assert.doesNotMatch(shared, /min-width:/); const credit = cssDeclarations(".credit-button", styles); assert.doesNotMatch(credit, /min-width:\s*64px/); const hit = cssDeclarations( ".chat-header-actions .credit-button::before,\n.chat-header-rectification .rectification-board-peek::before", styles, ); assert.match(hit, /inset:\s*-2px/); }); test("compact chat header is 52px plus the safe-area inset", () => { assert.match( styles, /\.chat-panel \{ grid-template-rows: calc\(52px \+ env\(safe-area-inset-top\)\) minmax\(0, 1fr\) auto; \}/, ); assert.match( styles, /\.chat-panel\.is-rectification \{ grid-template-rows: calc\(52px \+ env\(safe-area-inset-top\)\) minmax\(0, 1fr\); \}/, ); assert.match(styles, /\.chat-panel \{[^}]*grid-template-rows:\s*46px minmax\(0,\s*1fr\) auto/); });