fix(web): stop prompt font size changing on window resize (#2103)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-07-31 18:29:56 +01:00
committed by GitHub
parent 272fa2d34d
commit bed0d44ce0
5 changed files with 166 additions and 5 deletions
@@ -9,10 +9,20 @@ import * as tiptapEditor from "./use-tiptap-editor";
import { decideHistoryNav } from "./tiptap-editor-history";
describe("TIPTAP_EDITOR_TEXT_SIZE_CLASS", () => {
it("keeps 16px text until the lg breakpoint", () => {
expect(TIPTAP_EDITOR_TEXT_SIZE_CLASS).toContain("text-base");
expect(TIPTAP_EDITOR_TEXT_SIZE_CLASS).toContain("lg:text-sm");
expect(TIPTAP_EDITOR_TEXT_SIZE_CLASS).not.toContain("md:text-sm");
it("uses one text size at every viewport width", () => {
expect(TIPTAP_EDITOR_TEXT_SIZE_CLASS).toContain("text-sm");
expect(TIPTAP_EDITOR_TEXT_SIZE_CLASS).not.toContain("text-base");
});
it("carries no variant-prefixed text utility, so resizing cannot change the font", () => {
// The touch 16px floor lives in the `any-pointer: coarse` rule in
// globals.css; a width breakpoint here would resize the composer text when
// a desktop window is dragged narrow. Reject *any* `<variant>:text-*`
// rather than a list of named breakpoints — an arbitrary variant such as
// `min-[1024px]:text-lg` or `max-[900px]:text-base` is just as
// viewport-dependent and would slip past an enumerated pattern.
const variantTextUtility = /(?:^|\s)\S+:text-\S+/;
expect(TIPTAP_EDITOR_TEXT_SIZE_CLASS).not.toMatch(variantTextUtility);
});
});
@@ -51,7 +51,14 @@ export type TipTapInputHandle = {
};
const lowlightInstance = createLowlight(common);
export const TIPTAP_EDITOR_TEXT_SIZE_CLASS = "text-base leading-relaxed lg:text-sm";
/** Viewport-width-independent on purpose. The 16px floor that stops iOS Safari
* from auto-zooming a focused field is owned by the `@media (any-pointer:
* coarse)` rule in `app/globals.css`, which is keyed on the input device
* rather than the window size. A width breakpoint here (the old
* `text-base … lg:text-sm`) made the composer text jump from 14px to 16px when
* a desktop window was dragged narrower than `lg`, which is not a touch
* device and needs no zoom guard. */
export const TIPTAP_EDITOR_TEXT_SIZE_CLASS = "text-sm leading-relaxed";
type UseTipTapEditorOptions = {
value: string;
@@ -0,0 +1,39 @@
import { type Page } from "@playwright/test";
import { SessionPage } from "../../pages/session-page";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import type { CreateTaskResponse } from "../../../lib/types/http";
/** Shared setup for the desktop and mobile composer font-size specs. */
export async function createReadyTask(
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<CreateTaskResponse> {
return apiClient.createTaskWithAgent(seedData.workspaceId, title, seedData.agentProfileId, {
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
}
export async function openTaskChat(page: Page, taskId: string): Promise<SessionPage> {
await page.goto(`/t/${taskId}`);
const session = new SessionPage(page);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
return session;
}
/**
* The prompt composer of the active chat panel. Dockview and mobile layouts keep
* background panels mounted, and a chat transcript can host read-only
* `[contenteditable="false"]` ProseMirror decorations (mention chips, code-block
* views), so a bare `.tiptap.ProseMirror` match could measure the wrong element.
* Scope through `activeChat()` and require an editable host — the same locator
* `SessionPage.sendMessage()` uses.
*/
export function composerEditor(session: SessionPage) {
return session.activeChat().locator('.tiptap.ProseMirror[contenteditable="true"]').first();
}
@@ -0,0 +1,67 @@
import { test, expect } from "../../fixtures/test-base";
import { composerEditor, createReadyTask, openTaskChat } from "./chat-input-font-size-helpers";
/** Widths on both sides of Tailwind's `lg` (1024px) breakpoint. The composer
* used to carry `text-base … lg:text-sm`, so dragging a desktop window under
* 1024px grew the prompt text from 14px to 16px. */
const WIDTHS = [1440, 1100, 900, 700] as const;
/** Typed into the composer purely so the screenshots show real glyphs to
* compare across widths. */
const SAMPLE_PROMPT = "The quick brown fox jumps over the lazy dog";
test.describe("Chat input font size", () => {
test("stays constant while the desktop window is resized", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
const task = await createReadyTask(apiClient, seedData, "Chat Input Font Size");
const session = await openTaskChat(testPage, task.id);
const editor = composerEditor(session);
await expect(editor).toBeVisible();
// Desktop Chrome exposes a fine pointer, so the globals.css 16px
// coarse-pointer floor must not apply here. Assert it, otherwise this test
// would pass trivially with every width pinned to 16px.
const coarse = await testPage.evaluate(() => matchMedia("(any-pointer: coarse)").matches);
expect(coarse).toBe(false);
const sizes: number[] = [];
for (const width of WIDTHS) {
await testPage.setViewportSize({ width, height: 900 });
// Narrow widths swap the dockview layout, which remounts the composer.
// Re-await visibility so the measurement never lands on a detached node
// mid-remount (getComputedStyle on a detached element yields "" → NaN).
await expect(editor).toBeVisible();
await expect
.poll(async () => editor.evaluate((el) => getComputedStyle(el).fontSize))
.not.toBe("");
sizes.push(await editor.evaluate((el) => parseFloat(getComputedStyle(el).fontSize)));
}
expect(sizes).toEqual(sizes.map(() => sizes[0]));
expect(sizes[0]).toBeCloseTo(14, 1);
// Capture the wide/narrow pair for the PR description (only when
// CAPTURE_PR_ASSETS=true). The same prompt text is typed once and shot at
// both widths so the two images can be compared directly.
await testPage.setViewportSize({ width: 1440, height: 900 });
await expect(editor).toBeVisible();
await editor.click();
await editor.pressSequentially(SAMPLE_PROMPT);
await expect(editor).toContainText(SAMPLE_PROMPT);
await prCapture.screenshot("composer-1440px", {
caption: "Composer at 1440px (above the lg breakpoint) — prompt text at 14px",
});
await testPage.setViewportSize({ width: 900, height: 900 });
await expect(editor).toBeVisible();
await expect(editor).toContainText(SAMPLE_PROMPT);
await prCapture.screenshot("composer-900px", {
caption: "Composer at 900px (below the lg breakpoint) — prompt text still 14px, was 16px",
});
});
});
@@ -0,0 +1,38 @@
import { test, expect } from "../../fixtures/test-base";
import { composerEditor, createReadyTask, openTaskChat } from "./chat-input-font-size-helpers";
// Runs on the mobile-chrome Playwright project (Pixel 5 emulation, touch →
// any-pointer: coarse). The composer's Tailwind class is a flat `text-sm`, so
// touch devices depend entirely on the 16px coarse-pointer rule in globals.css
// to stop iOS Safari auto-zooming on focus. This guards that dependency —
// mobile-zoom.spec.ts covers plain form controls, this covers the
// contenteditable TipTap editor.
test.describe("Mobile chat input font size", () => {
test("composer renders at >= 16px to prevent iOS focus-zoom", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
const task = await createReadyTask(apiClient, seedData, "Mobile Chat Input Font Size");
const session = await openTaskChat(testPage, task.id);
// Guard: the 16px rule is gated on `@media (any-pointer: coarse)`. Assert
// the emulated device really exposes a coarse pointer so a fixture change
// that drops touch emulation fails loudly instead of silently passing.
const coarse = await testPage.evaluate(() => matchMedia("(any-pointer: coarse)").matches);
expect(coarse).toBe(true);
const editor = composerEditor(session);
await expect(editor).toBeVisible();
await editor.tap();
const fontSize = await editor.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
expect(fontSize).toBeGreaterThanOrEqual(16);
await editor.pressSequentially("The quick brown fox jumps over the lazy dog");
await prCapture.screenshot("mobile-composer", {
caption: "Touch composer keeps the 16px iOS focus-zoom floor from the coarse-pointer rule",
});
});
});