From acca82b92fbd63cfbbc2594dd0c76a44ac1ae275 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 6 Sep 2026 19:42:28 +0800 Subject: [PATCH 1/2] fix(desktop): square the ghost answer bubble under paint containment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assistant answer bubble is an Astryx ghost ChatMessageBubble: no fill, no border, and the 28px --radius-chat it inherits paints nothing. #4259 made that bubble a content-visibility boundary, and paint containment clips to the rounded padding box, so the invisible corners became clip arcs. In the app's Electron 43 (Chromium 150) the arc shaves the top-left of the first glyph of the first line and the bottom-left of the last line: 环 renders as 不 with a stub, a list's "3." loses its 3. #4906 added overflow-clip-margin: 2px against the same symptom. That only pushes the arc out by 2px; in Electron the glyph still loses its corner, and the pixel check that passed there ran in Playwright's headless Chromium, where the rounded clip does not reproduce. The margin does nothing once the radius is gone, measured at 0px and 2px on CJK and Latin samples, so it goes. border-radius: 0 on the ghost bubble is legal under DESIGN.md's Full-Bleed Rule (width 100%, flush both sides) and changes no pixel of its own: the bubble has nothing to round. DESIGN.md's chat rung row now says so. The WideAssistantProse play asserts the boundary is square instead of asserting a clip margin. Fixes #4898 Generated-by: Claude Code --- DESIGN.md | 2 +- apps/desktop/src/renderer/styles/chat-message.css | 11 +++++------ apps/desktop/stories/app-shell.stories.tsx | 7 +++---- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 3b6c7bfba3..110ce19bc0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -148,7 +148,7 @@ Nothing interactive is square. One ladder, assigned monotonically by box height: | 6px | control | inner | chips, keycaps, nested inlays, and product-drawn compact controls | | 10px | surface | element | cards, rows-as-cards, list containers; Astryx `Button`, `Input`, `SegmentedControl` | | 12px | modal | container | modals, panels, portal surfaces; Astryx `Card`, `Dialog`, `DropdownMenu` | -| 28px | chat | chat | the conversation surface as one shape: user bubble, assistant bubble, composer dock. Sourced from Astryx's `--radius-chat` by `ChatMessageBubble` and `ChatComposer` themselves; product CSS never restates it | +| 28px | chat | chat | the conversation surface as one shape: user bubble, composer dock. Sourced from Astryx's `--radius-chat` by `ChatMessageBubble` and `ChatComposer` themselves; product CSS never restates it. The assistant bubble is `ghost` and full-bleed, so it paints no corners and is squared under the Full-Bleed Rule: as a paint-containment boundary its radius would clip glyphs | | pill | pill | full | badges, pills, circular controls | - **The Two-Name Rule.** These are one ladder under two vocabularies, and the names never line up: Maka's `control` is Astryx's `inner`, Maka's `surface` is Astryx's `element`, Maka's `modal` is Astryx's `container`, Maka's `pill` is Astryx's `full`. Resolve a tier from the box, never from the token name that sounds right. **The px side is the authority and the Astryx name is an alias to it** β€” `makaTheme.ts` emits `--radius-element: var(--radius-surface)` and its three siblings, so an upstream rung change cannot move one name out from under the other, and the ladder cannot be half in px and half in rem (which the 16px-root note in that file is the reason to avoid). Both vocabularies are live in product CSS and both are correct to read; they resolve to the same number by construction rather than by coincidence. The chat rung is the one tier the product does not assign: `ChatMessageBubble` and `ChatComposer` both resolve `--radius-chat` on their own, which is why the bubble and the dock round together, and why setting a bubble radius or a non-default `density` in product code silently breaks the pair. Astryx's `--radius-page` carries the same 28px literal but is a different token with no Maka tier and no product consumer; reaching for it to match the chat surface is inventing a rung. It is emitted in px like the rest β€” it shipped as `1.75rem`, which is the same number at a 16px root and a different one the moment anything touches the root, and a ladder cannot have one rung that moves with the type scale. diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 50c376205e..a09fb0cc73 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -112,8 +112,13 @@ white-space: pre-wrap; } +/* The ghost bubble has no fill or border, so its inherited 28px radius paints + nothing. It is also a transcript boundary below, and paint containment clips + to the rounded padding box, so the invisible corners became clip arcs that + shaved the first glyph of the first line and the last. Square it. */ .maka-chat-message-bubble-assistant { padding: var(--space-0-5) 0 0; + border-radius: 0; } .maka-processing-sequence { @@ -136,12 +141,6 @@ anchoring when a skipped block leaves and re-enters the viewport. */ .maka-chat-message-list [data-maka-transcript-boundary] { content-visibility: auto; - /* `content-visibility` supplies paint containment as well as skipping. - Assistant ghost bubbles are deliberately flush-left, and Chromium can - rasterize the first CJK glyph a fraction outside that content box; at a - zero-width clip edge it then shaves the glyph's leading stroke. Preserve - a tiny paint apron while keeping the boundary independently skippable. */ - overflow-clip-margin: 2px; /* First-paint intrinsic-size ESTIMATE for scroll-anchor stability, not a fixed height: `auto px` still grows to the block's real size after paint. 96px is the single-line answer baseline; tall multi-line blocks diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index f39dd02b14..eaced31e7d 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -969,11 +969,10 @@ export const WideAssistantProse: Story = { const turnRect = turn.getBoundingClientRect(); expect(turnRect.width).toBeGreaterThan(680); expect(turnRect.right - paragraph.getBoundingClientRect().right).toBeLessThanOrEqual(1); - // `content-visibility: auto` implies paint containment. CJK glyph ink can - // extend a fraction past its line box, so a flush-left answer needs a - // small clip margin or Chromium shaves the first glyph on every line. + // `content-visibility: auto` implies paint containment, which clips to the + // rounded padding box: a boundary with a radius shaves the corner glyphs. expect(getComputedStyle(boundary).contentVisibility).toBe('auto'); - expect(Number.parseFloat(getComputedStyle(boundary).overflowClipMargin)).toBeGreaterThanOrEqual(2); + expect(getComputedStyle(boundary).borderTopLeftRadius).toBe('0px'); }, }; From 82bce96f1a0d8ff86a4a14afd4bbe3007fc35879 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 6 Sep 2026 20:48:30 +0800 Subject: [PATCH 2/2] test(desktop): pin every clipping ancestor of the answer prose square The WideAssistantProse play asserted only the boundary bubble's own radius. Headless Chromium does not reproduce the rounded paint clip, so the story can never see the symptom; what it can pin is the geometry that produces it. Walk from the paragraph to the transcript scroller and require every ancestor that clips (content-visibility auto, paint containment, non-visible overflow) to be square or to pad its content past its corner radius. On main's CSS this fails naming the bubble: 28px corner, 0px padding. Generated-by: Claude Code --- apps/desktop/stories/app-shell.stories.tsx | 35 ++++++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index eaced31e7d..fdc1378a40 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -969,10 +969,39 @@ export const WideAssistantProse: Story = { const turnRect = turn.getBoundingClientRect(); expect(turnRect.width).toBeGreaterThan(680); expect(turnRect.right - paragraph.getBoundingClientRect().right).toBeLessThanOrEqual(1); - // `content-visibility: auto` implies paint containment, which clips to the - // rounded padding box: a boundary with a radius shaves the corner glyphs. expect(getComputedStyle(boundary).contentVisibility).toBe('auto'); - expect(getComputedStyle(boundary).borderTopLeftRadius).toBe('0px'); + // Paint containment (`content-visibility: auto`, `contain: paint`, + // `overflow` other than visible) clips to the rounded padding box, and + // headless Chromium does not reproduce that clip, so pin the geometry: + // every clipping ancestor up to the scroller is square, or its padding + // keeps the prose out of its corners. + const scroller = paragraph.closest('[data-chat-scroll-container="true"]'); + if (!scroller) throw new Error('Wide assistant paragraph did not render inside the transcript scroller'); + for (let ancestor = paragraph.parentElement; ancestor && ancestor !== scroller; ancestor = ancestor.parentElement) { + const style = getComputedStyle(ancestor); + const clips = + style.contentVisibility === 'auto' || + style.contain.includes('paint') || + style.contain === 'strict' || + style.contain === 'content' || + style.overflow !== 'visible'; + if (!clips) continue; + const radius = Math.max( + ...[ + style.borderTopLeftRadius, + style.borderTopRightRadius, + style.borderBottomLeftRadius, + style.borderBottomRightRadius, + ].map(Number.parseFloat), + ); + const inset = Math.min( + ...[style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft].map(Number.parseFloat), + ); + expect( + inset, + `${ancestor.tagName.toLowerCase()}.${ancestor.className} clips with a ${radius}px corner but only ${inset}px of padding`, + ).toBeGreaterThanOrEqual(radius); + } }, };