fix(web): stop prompt font size changing on window resize (#2103)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user