From 3acdc3a14e7a9d3c9cc19d4e80e6ac1745299719 Mon Sep 17 00:00:00 2001 From: nabettu Date: Mon, 19 Oct 2026 11:40:12 +0900 Subject: [PATCH 1/4] fix(web): single-digit numbered lists no longer clip with wide fonts Chat ordered lists only widened their marker gutter for multi-digit markers, so a single-digit list kept the fixed 1.25rem gutter and a wide interface font (monospace, Monocraft) drew `1. ` past it, clipping the digit at the container edge. The gutter is now max(1.25rem, (digits + 2)ch) for every list, matching the multi-digit sizing. Fixes #21008 Co-Authored-By: Claude Opus 5.5 --- .../src/components/ChatMarkdown.logic.test.ts | 18 ++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 17 +++++++++++------ apps/web/src/index.css | 8 ++++---- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.logic.test.ts b/apps/web/src/components/ChatMarkdown.logic.test.ts index e93edec037462..f7b05931d07be 100644 --- a/apps/web/src/components/ChatMarkdown.logic.test.ts +++ b/apps/web/src/components/ChatMarkdown.logic.test.ts @@ -3,9 +3,27 @@ import { describe, expect, it } from "vite-plus/test"; import { canUseMarkdownFileShellActions, + orderedListGutterStyle, shouldUseMarkdownFileBrowserPrimaryAction, } from "./ChatMarkdown"; +describe("orderedListGutterStyle", () => { + it("fits a single-digit marker and its space in wide fonts without narrowing the rem gutter", () => { + expect(orderedListGutterStyle(3, undefined)).toEqual({ + "--list-gutter": "max(1.25rem, 3ch)", + }); + expect(orderedListGutterStyle(0, undefined)).toEqual({ + "--list-gutter": "max(1.25rem, 3ch)", + }); + }); + + it("sizes the gutter by the widest marker in the list", () => { + expect(orderedListGutterStyle(10, 1)).toEqual({ "--list-gutter": "max(1.25rem, 4ch)" }); + expect(orderedListGutterStyle(2, "99")).toEqual({ "--list-gutter": "max(1.25rem, 5ch)" }); + expect(orderedListGutterStyle(1, 100)).toEqual({ "--list-gutter": "max(1.25rem, 5ch)" }); + }); +}); + describe("canUseMarkdownFileShellActions", () => { const environmentId = EnvironmentId.make("environment-1"); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a608ecb0298b5..fe36e3265ed16 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -382,16 +382,18 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb return listItemStart + firstLine.indexOf(match[1]); } -function orderedListGutterStyle( +// A marker draws its digits, the period, and a trailing space, which is +// (digits + 2)ch in a monospace or similarly wide interface font. The 1.25rem +// floor keeps the default indent wherever that is narrower. +export function orderedListGutterStyle( itemCount: number, start: unknown, -): { "--list-gutter": string } | undefined { +): { "--list-gutter": string } { const parsedStart = Number.parseInt(String(start ?? 1), 10); const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; const lastNumber = firstNumber + Math.max(itemCount - 1, 0); const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); - if (markerWidth <= 1) return undefined; - return { "--list-gutter": `${markerWidth + 2}ch` }; + return { "--list-gutter": `max(1.25rem, ${markerWidth + 2}ch)` }; } const GITHUB_ALERT_PRESENTATIONS: Record< @@ -2787,9 +2789,12 @@ const CHAT_MARKDOWN_COMPONENTS = { const itemCount = node?.children?.filter((child) => child.type === "element" && child.tagName === "li") .length ?? 0; - const gutterStyle = orderedListGutterStyle(itemCount, start); return ( -
    +
      ); }, li: function MarkdownListItem({ node, children, ...props }) { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 63555806aa8ce..c3bd1fe5796ca 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1835,10 +1835,10 @@ code { } /* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but - ChatMarkdown's `ol` renderer widens it (via inline style) for lists whose - widest marker has multiple characters so it isn't clipped by list-style-position: - outside painting the marker past the padding box. Reset it here too so a - nested ol without its own widened marker doesn't inherit the outer one. */ + ChatMarkdown's `ol` renderer widens it (via inline style) to fit its widest + marker in `ch`, so list-style-position: outside doesn't paint the marker past + the padding box. Reset it here too so a nested ol without its own widened + marker doesn't inherit the outer one. */ :is(.chat-markdown, .composer-tiptap) ol { --list-gutter: 1.25rem; padding-left: var(--list-gutter, 1.25rem); From ac39084b50ac4a976b19a6273237976b4f3b92a2 Mon Sep 17 00:00:00 2001 From: nabettu Date: Fri, 23 Oct 2026 12:32:00 +0900 Subject: [PATCH 2/4] fix(web): preserve list spacing for markers that already fit --- .../components/ChatMarkdown.browser.test.tsx | 73 ++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 95 +++++++++++++++---- apps/web/src/index.css | 7 +- 3 files changed, 154 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index e30b81c5e5f93..6af1b3fadd2fa 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -91,6 +91,79 @@ function buttonsLabelled(container: HTMLElement, label: string) { ); } +describe("ChatMarkdown ordered list layout", () => { + it("preserves default-font indentation and wrapping when the markers already fit", async () => { + setup(); + const container = document.createElement("div"); + Object.assign(container.style, { font: "14px system-ui", width: "240px" }); + await using screen = await render( + , + { container }, + ); + const list = screen.container.querySelector("ol")!; + const item = list.querySelector("li")!; + const padding = getComputedStyle(list).paddingInlineStart; + const bounds = item.getBoundingClientRect(); + list.style.setProperty("--list-gutter", "1.25rem"); + expect(padding).toBe(getComputedStyle(list).paddingInlineStart); + expect(item.getBoundingClientRect().toJSON()).toEqual(bounds.toJSON()); + }); + + it("keeps wide monospace markers inside the clipping edge, including nested lists", async () => { + setup(); + const container = document.createElement("div"); + Object.assign(container.style, { font: "22px monospace", width: "300px", overflow: "hidden" }); + await using screen = await render( + , + { container }, + ); + const probe = document.createElement("span"); + probe.style.whiteSpace = "pre"; + probe.textContent = "2. "; + container.append(probe); + const markerWidth = probe.getBoundingClientRect().width; + probe.remove(); + expect(markerWidth).toBeGreaterThan(20); + for (const list of screen.container.querySelectorAll("ol")) { + const item = list.querySelector("li")!; + expect(item.getBoundingClientRect().left - markerWidth).toBeGreaterThanOrEqual( + list.getBoundingClientRect().left, + ); + } + }); + + it("remeasures existing lists when the inherited interface font changes", async () => { + setup(); + const container = document.createElement("div"); + Object.assign(container.style, { + font: "22px var(--test-ordered-list-font, system-ui)", + width: "300px", + }); + await using screen = await render(, { + container, + }); + const list = screen.container.querySelector("ol")!; + const originalPadding = Number.parseFloat(getComputedStyle(list).paddingInlineStart); + try { + document.documentElement.style.setProperty("--test-ordered-list-font", "monospace"); + await expect + .poll(() => Number.parseFloat(getComputedStyle(list).paddingInlineStart)) + .toBeGreaterThan(originalPadding); + document.documentElement.style.removeProperty("--test-ordered-list-font"); + await expect + .poll(() => Number.parseFloat(getComputedStyle(list).paddingInlineStart)) + .toBe(originalPadding); + } finally { + document.documentElement.style.removeProperty("--test-ordered-list-font"); + } + }); +}); + describe("ChatMarkdown favicon privacy", () => { it("suppresses private link images while preserving public links across updates", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 13af2e428bb95..fe2605315acf2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -325,15 +325,87 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb return listItemStart + firstLine.indexOf(match[1]); } -// A marker draws its digits, the period, and a trailing space, which is -// (digits + 2)ch in a monospace or similarly wide interface font. The 1.25rem -// floor keeps the default indent wherever that is narrower. -function orderedListGutterStyle(itemCount: number, start: unknown): { "--list-gutter": string } { +function orderedListGutterStyle(itemCount: number, start: unknown) { const parsedStart = Number.parseInt(String(start ?? 1), 10); const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; const lastNumber = firstNumber + Math.max(itemCount - 1, 0); const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); - return { "--list-gutter": `max(1.25rem, ${markerWidth + 2}ch)` }; + return markerWidth > 1 ? { "--list-gutter": `${markerWidth + 2}ch` } : undefined; +} + +function MarkdownOrderedList({ + node, + start, + style, + ...props +}: ComponentProps<"ol"> & ReactMarkdownExtraProps) { + const ref = useRef(null); + const itemCount = + node?.children?.filter((child) => child.type === "element" && child.tagName === "li").length ?? + 0; + const gutterStyle = orderedListGutterStyle(itemCount, start); + const hasWideMarker = gutterStyle !== undefined; + + useLayoutEffect(() => { + const list = ref.current; + if (!list || hasWideMarker) return; + const measure = () => { + const marker = getComputedStyle(list.querySelector("li") ?? list, "::marker"); + const probe = document.createElement("span"); + Object.assign(probe.style, { + position: "fixed", + top: "0", + left: "0", + visibility: "hidden", + whiteSpace: "pre", + fontFamily: marker.fontFamily, + fontSize: marker.fontSize, + fontWeight: marker.fontWeight, + fontStyle: marker.fontStyle, + fontStretch: marker.fontStretch, + fontVariantNumeric: "tabular-nums", + fontFeatureSettings: marker.fontFeatureSettings, + fontVariationSettings: marker.fontVariationSettings, + letterSpacing: marker.letterSpacing, + wordSpacing: marker.wordSpacing, + }); + // `ch` overestimates punctuation in proportional fonts. Measure the actual + // marker so fonts whose numbers already fit keep their indent and wrapping. + const markers = Array.from({ length: 10 }, (_, digit) => { + const span = document.createElement("span"); + span.textContent = `${digit}. `; + probe.append(span); + return span; + }); + document.body.append(probe); + const width = Math.ceil( + Math.max(...markers.map((span) => span.getBoundingClientRect().width)), + ); + probe.remove(); + list.style.setProperty("--list-gutter", `max(1.25rem, ${width}px)`); + }; + measure(); + // Appearance settings apply inherited fonts to the root after React commits. + const observer = new MutationObserver(measure); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["style", "class"], + }); + document.fonts.addEventListener("loadingdone", measure); + return () => { + observer.disconnect(); + document.fonts.removeEventListener("loadingdone", measure); + }; + }, [hasWideMarker]); + + return ( +
        + ); } const GITHUB_ALERT_PRESENTATIONS: Record< @@ -993,18 +1065,7 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, - ol: function MarkdownOrderedList({ node, start, style, ...props }) { - const itemCount = - node?.children?.filter((child) => child.type === "element" && child.tagName === "li") - .length ?? 0; - return ( -
          - ); - }, + ol: MarkdownOrderedList, li: function MarkdownListItem({ node, children, ...props }) { const { skills } = use(ChatMarkdownRendererContext); // The checkbox finds its marker from this offset when toggled, so list items diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6c4a515349b03..be6d9fe61f8fe 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1853,10 +1853,9 @@ code { } /* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but - ChatMarkdown's `ol` renderer widens it (via inline style) to fit its widest - marker in `ch`, so list-style-position: outside doesn't paint the marker past - the padding box. Reset it here too so a nested ol without its own widened - marker doesn't inherit the outer one. */ + ChatMarkdown's `ol` renderer measures single-digit markers and widens it only + when needed. Multi-digit lists use a digit-count-based `ch` gutter. Reset it + here too so nested lists don't inherit their parent's widened gutter. */ :is(.chat-markdown, .composer-tiptap) ol { --list-gutter: 1.25rem; padding-inline-start: var(--list-gutter, 1.25rem); From a7e92d94beed3552ce0893dfbc8ced1980e3a165 Mon Sep 17 00:00:00 2001 From: nabettu Date: Fri, 23 Oct 2026 13:21:41 +0900 Subject: [PATCH 3/4] test(web): measure list markers at the rendered font size --- apps/web/src/components/ChatMarkdown.browser.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 6af1b3fadd2fa..cf9c8d8ce0353 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -123,6 +123,9 @@ describe("ChatMarkdown ordered list layout", () => { { container }, ); const probe = document.createElement("span"); + const listStyle = getComputedStyle(screen.container.querySelector("ol")!); + probe.style.fontFamily = listStyle.fontFamily; + probe.style.fontSize = listStyle.fontSize; probe.style.whiteSpace = "pre"; probe.textContent = "2. "; container.append(probe); From 79208d89c1e536fe1a13aa9b7aeccccbf10dcfc2 Mon Sep 17 00:00:00 2001 From: nabettu Date: Fri, 23 Oct 2026 13:22:33 +0900 Subject: [PATCH 4/4] test(web): cover ordered list gutter transitions --- .../src/components/ChatMarkdown.browser.test.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index cf9c8d8ce0353..c7a70bf51e317 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -165,6 +165,19 @@ describe("ChatMarkdown ordered list layout", () => { document.documentElement.style.removeProperty("--test-ordered-list-font"); } }); + + it("restores the single-digit gutter after reusing a list with wider numbers", async () => { + setup(); + const markdown = (text: string) => ; + await using screen = await render(markdown("1. First")); + const padding = () => + Number.parseFloat(getComputedStyle(screen.container.querySelector("ol")!).paddingInlineStart); + const originalPadding = padding(); + await screen.rerender(markdown("99. First\n100. Second")); + expect(padding()).toBeGreaterThan(originalPadding); + await screen.rerender(markdown("1. First")); + expect(padding()).toBe(originalPadding); + }); }); describe("ChatMarkdown favicon privacy", () => {