From bed0d44ce0d5cebe0b471a9d2c3e535a67e97af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Fri, 31 Jul 2026 18:29:56 +0100 Subject: [PATCH] fix(web): stop prompt font size changing on window resize (#2103) Co-authored-by: Kandev Agent --- .../task/chat/use-tiptap-editor.test.ts | 18 +++-- .../components/task/chat/use-tiptap-editor.ts | 9 ++- .../chat/chat-input-font-size-helpers.ts | 39 +++++++++++ .../tests/chat/chat-input-font-size.spec.ts | 67 +++++++++++++++++++ .../chat/mobile-chat-input-font-size.spec.ts | 38 +++++++++++ 5 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 apps/web/e2e/tests/chat/chat-input-font-size-helpers.ts create mode 100644 apps/web/e2e/tests/chat/chat-input-font-size.spec.ts create mode 100644 apps/web/e2e/tests/chat/mobile-chat-input-font-size.spec.ts diff --git a/apps/web/components/task/chat/use-tiptap-editor.test.ts b/apps/web/components/task/chat/use-tiptap-editor.test.ts index 5f3e2578ab..d67b75ef06 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.test.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.test.ts @@ -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* `: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); }); }); diff --git a/apps/web/components/task/chat/use-tiptap-editor.ts b/apps/web/components/task/chat/use-tiptap-editor.ts index 91914e3f26..c0a2b798e6 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.ts @@ -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; diff --git a/apps/web/e2e/tests/chat/chat-input-font-size-helpers.ts b/apps/web/e2e/tests/chat/chat-input-font-size-helpers.ts new file mode 100644 index 0000000000..606ebbcfbb --- /dev/null +++ b/apps/web/e2e/tests/chat/chat-input-font-size-helpers.ts @@ -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 { + 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 { + 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(); +} diff --git a/apps/web/e2e/tests/chat/chat-input-font-size.spec.ts b/apps/web/e2e/tests/chat/chat-input-font-size.spec.ts new file mode 100644 index 0000000000..d4b2af314d --- /dev/null +++ b/apps/web/e2e/tests/chat/chat-input-font-size.spec.ts @@ -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", + }); + }); +}); diff --git a/apps/web/e2e/tests/chat/mobile-chat-input-font-size.spec.ts b/apps/web/e2e/tests/chat/mobile-chat-input-font-size.spec.ts new file mode 100644 index 0000000000..175443c7f6 --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-chat-input-font-size.spec.ts @@ -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", + }); + }); +});