diff --git a/ui/src/e2e/chat-markdown-alignment.e2e.test.ts b/ui/src/e2e/chat-markdown-alignment.e2e.test.ts index 2d4c715c475d..cfcbbd55ae88 100644 --- a/ui/src/e2e/chat-markdown-alignment.e2e.test.ts +++ b/ui/src/e2e/chat-markdown-alignment.e2e.test.ts @@ -105,8 +105,7 @@ suite.define(() => { if (!collapsedSummary || !jsonCollapse || !jsonSummary || !jsonCopy) { throw new Error("Missing authored or JSON disclosure markup"); } - const closedChevronStyle = getComputedStyle(collapsedSummary, "::after"); - const collapsedSummaryRect = collapsedSummary.getBoundingClientRect(); + const closedChevronStyle = getComputedStyle(collapsedSummary, "::before"); const collapsedSummaryTextRect = textRect( "details:not(.json-collapse):not([open]) > summary", ); @@ -121,13 +120,13 @@ suite.define(() => { checkboxRect.y + checkboxRect.height / 2 - (taskTextRect.y + taskTextRect.height / 2), checkboxSize: checkboxRect.width, chevronClosedTransform: closedChevronStyle.transform, - chevronInlineEnd: closedChevronStyle.insetInlineEnd, + chevronInlineStart: closedChevronStyle.insetInlineStart, chevronTransitionDuration: closedChevronStyle.transitionDuration, chevronWidth: closedChevronStyle.width, - collapsedSummaryPaddingInlineEnd: getComputedStyle(collapsedSummary).paddingInlineEnd, - collapsedSummaryTextRight: collapsedSummaryTextRect.right, + collapsedSummaryPaddingInlineStart: + getComputedStyle(collapsedSummary).paddingInlineStart, collapsedSummaryTextX: collapsedSummaryTextRect.x, - collapsedSummaryRight: collapsedSummaryRect.right, + detailsRight: details.getBoundingClientRect().right, detailsX: details.getBoundingClientRect().x, jsonBorderInlineStartWidth: jsonCollapseStyle.borderInlineStartWidth, jsonCopyFloat: getComputedStyle(jsonCopy).float, @@ -135,38 +134,30 @@ suite.define(() => { jsonSummaryDisplay: jsonSummaryStyle.display, jsonSummaryPaddingInlineStart: jsonSummaryStyle.paddingInlineStart, numberedTextX: textRect("ol > li").x, + rootRight: root.getBoundingClientRect().right, rootX: root.getBoundingClientRect().x, summaryMarginBottom: summaryStyle.marginBottom, - summaryTextX: textRect("details[open] > summary").x, taskTextX: taskTextRect.x, }; }); - const textStarts = [ - geometry.bulletTextX, - geometry.numberedTextX, - geometry.taskTextX, - geometry.summaryTextX, - geometry.collapsedSummaryTextX, - ]; + const textStarts = [geometry.bulletTextX, geometry.numberedTextX, geometry.taskTextX]; expect(Math.max(...textStarts) - Math.min(...textStarts)).toBeLessThanOrEqual(1); expect(geometry.checkboxGap).toBeGreaterThanOrEqual(7); expect(geometry.checkboxGap).toBeLessThanOrEqual(9); expect(Math.abs(geometry.checkboxLineCenterDelta)).toBeLessThanOrEqual(1); expect(geometry.checkboxSize).toBe(16); - expect(geometry.bodyTextX - geometry.rootX).toBeGreaterThanOrEqual(28); - expect(geometry.detailsX).toBeGreaterThan(geometry.rootX); - expect(geometry.detailsX).toBeLessThan(geometry.bodyTextX); + expect(geometry.bodyTextX).toBeGreaterThan(geometry.detailsX); + expect(Math.abs(geometry.detailsX - geometry.rootX)).toBeLessThanOrEqual(1); + expect(Math.abs(geometry.detailsRight - geometry.rootRight)).toBeLessThanOrEqual(1); expect(Number.parseFloat(geometry.borderInlineStartWidth)).toBeGreaterThan(0); expect(Number.parseFloat(geometry.summaryMarginBottom)).toBeGreaterThan(0); expect(Number.parseFloat(geometry.chevronWidth)).toBe(16); - expect(Number.parseFloat(geometry.chevronInlineEnd)).toBe(0); - expect(Number.parseFloat(geometry.collapsedSummaryPaddingInlineEnd)).toBeGreaterThanOrEqual( - 24, - ); - expect(geometry.collapsedSummaryRight - geometry.collapsedSummaryTextRight).toBeGreaterThan( - 24, - ); + expect(Number.parseFloat(geometry.chevronInlineStart)).toBe(0); + expect( + Number.parseFloat(geometry.collapsedSummaryPaddingInlineStart), + ).toBeGreaterThanOrEqual(24); + expect(geometry.collapsedSummaryTextX - geometry.detailsX).toBeGreaterThan(24); expect(geometry.chevronTransitionDuration).not.toBe("0s"); expect(Math.abs(geometry.jsonDetailsX - geometry.rootX)).toBeLessThanOrEqual(1); expect(Number.parseFloat(geometry.jsonBorderInlineStartWidth)).toBe(0); @@ -178,14 +169,14 @@ suite.define(() => { await collapsedSummary.click(); await expect .poll(() => - collapsedSummary.evaluate((summary) => getComputedStyle(summary, "::after").transform), + collapsedSummary.evaluate((summary) => getComputedStyle(summary, "::before").transform), ) .not.toBe(geometry.chevronClosedTransform); }, ); }); - it("preserves the shared Markdown gutter in RTL transcripts", async () => { + it("preserves Markdown marker and disclosure alignment in RTL transcripts", async () => { await suite.withPage( { colorScheme: "light", @@ -252,19 +243,27 @@ suite.define(() => { const unorderedList = root.querySelector("ul:not(.contains-task-list)"); const orderedList = root.querySelector("ol"); const summary = root.querySelector("details:not(.json-collapse) > summary"); - if (!checkbox || !task || !unorderedList || !orderedList || !summary) { + const details = root.querySelector("details:not(.json-collapse)"); + if (!checkbox || !task || !unorderedList || !orderedList || !summary || !details) { throw new Error("Missing RTL Markdown geometry"); } const checkboxRect = checkbox.getBoundingClientRect(); + const detailsRect = details.getBoundingClientRect(); + const rootRect = root.getBoundingClientRect(); return { checkboxGap: checkboxRect.left - textRight(".task-list-item"), - chevronInlineEnd: getComputedStyle(summary, "::after").insetInlineEnd, + chevronInlineStart: getComputedStyle(summary, "::before").insetInlineStart, + detailsRight: detailsRect.right, + detailsX: detailsRect.x, orderedPaddingInlineStart: getComputedStyle(orderedList).paddingInlineStart, + rootRight: rootRect.right, + rootX: rootRect.x, + summaryPaddingInlineStart: getComputedStyle(summary).paddingInlineStart, + summaryTextRight: textRight("details:not(.json-collapse) > summary"), textStarts: [ textRight("ul:not(.contains-task-list) > li"), textRight("ol > li"), textRight(".task-list-item"), - textRight("details:not(.json-collapse) > summary"), ], unorderedPaddingInlineStart: getComputedStyle(unorderedList).paddingInlineStart, }; @@ -273,11 +272,15 @@ suite.define(() => { expect( Math.max(...geometry.textStarts) - Math.min(...geometry.textStarts), ).toBeLessThanOrEqual(1); - expect(Number.parseFloat(geometry.unorderedPaddingInlineStart)).toBe(32); - expect(Number.parseFloat(geometry.orderedPaddingInlineStart)).toBe(32); + expect(Number.parseFloat(geometry.unorderedPaddingInlineStart)).toBe(24); + expect(Number.parseFloat(geometry.orderedPaddingInlineStart)).toBe(24); expect(geometry.checkboxGap).toBeGreaterThanOrEqual(7); expect(geometry.checkboxGap).toBeLessThanOrEqual(9); - expect(Number.parseFloat(geometry.chevronInlineEnd)).toBe(0); + expect(Number.parseFloat(geometry.chevronInlineStart)).toBe(0); + expect(Number.parseFloat(geometry.summaryPaddingInlineStart)).toBeGreaterThanOrEqual(24); + expect(geometry.detailsRight - geometry.summaryTextRight).toBeGreaterThan(24); + expect(Math.abs(geometry.detailsX - geometry.rootX)).toBeLessThanOrEqual(1); + expect(Math.abs(geometry.detailsRight - geometry.rootRight)).toBeLessThanOrEqual(1); }, ); }); diff --git a/ui/src/e2e/route-css.e2e.test.ts b/ui/src/e2e/route-css.e2e.test.ts index e0b096aedf0d..e8b73fdeecf3 100644 --- a/ui/src/e2e/route-css.e2e.test.ts +++ b/ui/src/e2e/route-css.e2e.test.ts @@ -201,20 +201,17 @@ suite.define(() => { expect(chatMarkdownStyles.tableToCopyGap).toBeGreaterThan(0); expect(chatMarkdownStyles.taskListToDetailsGap).toBeGreaterThan(0); expect(chatMarkdownStyles.unorderedToOrderedGap).toBeGreaterThan(0); - expect(chatMarkdownStyles.quoteMargin / chatMarkdownStyles.blockFontSize).toBeCloseTo( - 0.75, - 2, - ); + expect(chatMarkdownStyles.quoteMargin / chatMarkdownStyles.blockFontSize).toBeCloseTo(1, 2); expect(chatMarkdownStyles.tableCopyMargin / chatMarkdownStyles.blockFontSize).toBeCloseTo( - 0.75, + 1, 2, ); expect(chatMarkdownStyles.detailsMargin / chatMarkdownStyles.blockFontSize).toBeCloseTo( - 0.75, + 1, 2, ); expect(chatMarkdownStyles.orderedMargin / chatMarkdownStyles.blockFontSize).toBeCloseTo( - 0.75, + 1, 2, ); }, diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index a6aa5811bec0..85d17604b038 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -93,9 +93,16 @@ /* Keep every adjacent top-level Markdown block on the same rhythm, including special blocks that otherwise read as part of the preceding block. */ .chat-text - :where(p, ul, ol, pre, blockquote, table, details) + > :where(p, ul, ol, pre, blockquote, table, details) + :where(p, ul, ol, pre, blockquote, table, details) { - margin-top: 0.75em; + margin-top: 1em; +} + +.chat-text + :where(blockquote, details, li) + > :where(p, ul, ol, pre, blockquote, table, details) + + :where(p, ul, ol, pre, blockquote, table, details) { + margin-top: 0.5em; } /* Tables retain top margin even when first; the adjacent-block rule above gives @@ -167,7 +174,7 @@ } :is(.chat-text, .chat-thinking) { - --chat-markdown-indent: var(--space-7); + --chat-markdown-indent: var(--space-6); --chat-markdown-marker-gap: var(--space-2); } @@ -175,15 +182,14 @@ padding-inline-start: var(--chat-markdown-indent); } -:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)) { - margin-inline-start: var(--space-3); - padding-inline-start: calc(var(--chat-markdown-indent) - var(--space-3)); -} - .chat-text :where(li + li) { margin-top: 0.4em; } +.chat-text :where(li > ul, li > ol) { + margin-top: 0.2em; +} + /* Keep bullets, checkboxes, and numbers in one marker column while their text shares the same start edge. */ :is(.chat-text, .chat-thinking) :where(ul > li:not(.task-list-item))::marker { @@ -192,6 +198,8 @@ :is(.chat-text, .chat-thinking) :where(ul > .task-list-item) { list-style: none; + margin-inline-start: calc(-1 * var(--chat-markdown-indent)); + padding-inline-start: calc(var(--space-4) + var(--chat-markdown-marker-gap)); } :is(.chat-text, .chat-thinking) :where(ol > li)::marker { @@ -204,18 +212,36 @@ :is(.chat-text, .chat-thinking) :where(ul > li:not(.task-list-item))::before { position: absolute; - inset-inline-end: calc(100% + var(--chat-markdown-marker-gap)); + inset-inline-start: calc(-1 * var(--chat-markdown-indent)); content: "•"; inset-block-start: 0; inline-size: calc(var(--chat-markdown-indent) - var(--chat-markdown-marker-gap)); text-align: end; + color: var(--muted); + font-size: calc(1em + 1px); } -/* Authored disclosures align with list text but place their affordance at the - row edge; JSON code blocks retain their separate compact presentation. */ +:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)) { + box-sizing: border-box; + width: 100%; + padding: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: transparent; +} + +:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse) details:not(.json-collapse)) { + width: calc(100% - var(--space-3)); + margin-top: var(--space-3); + margin-inline-start: var(--space-3); +} + +/* Authored disclosures are complete content surfaces; JSON code blocks retain + their separate compact presentation. */ :is(.chat-text, .chat-thinking) :where(details:not(.json-collapse) > summary) { position: relative; - padding-inline-end: var(--space-7); + min-height: var(--space-5); + padding-inline-start: var(--space-6); list-style: none; cursor: var(--cursor-action); } @@ -225,11 +251,11 @@ display: none; } -:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse) > summary)::after { +:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse) > summary)::before { position: absolute; content: ""; inset-block-start: calc((1lh - var(--space-4)) / 2); - inset-inline-end: 0; + inset-inline-start: 0; inline-size: var(--space-4); block-size: var(--space-4); background: var(--select-chevron) center / var(--space-4) no-repeat; @@ -237,22 +263,29 @@ transition: transform var(--duration-normal) var(--ease-out); } -:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)[open] > summary)::after { +:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)[open] > summary)::before { transform: rotate(0deg); } :is(.chat-text, .chat-thinking) :where(.task-list-item-checkbox) { + appearance: none; position: absolute; inset-block-start: calc((1lh - var(--space-4)) / 2); - inset-inline-end: calc(100% + var(--chat-markdown-marker-gap)); + inset-inline-start: 0; inline-size: var(--space-4); block-size: var(--space-4); margin: 0; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--bg); + opacity: 1; } -:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)[open]) { - padding-inline-start: calc(var(--chat-markdown-indent) - var(--space-3) - 1px); - border-inline-start: 1px solid var(--border); +:is(.chat-text, .chat-thinking) :where(.task-list-item-checkbox:checked) { + border-color: color-mix(in srgb, var(--accent) 72%, var(--border)); + background: color-mix(in srgb, var(--accent) 72%, var(--bg)) + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m3 8 3 3 7-7'/%3E%3C/svg%3E") + center / 13px no-repeat; } :is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)[open] > summary) { @@ -260,7 +293,7 @@ } @media (prefers-reduced-motion: reduce) { - :is(.chat-text, .chat-thinking) :where(details:not(.json-collapse) > summary)::after { + :is(.chat-text, .chat-thinking) :where(details:not(.json-collapse) > summary)::before { transition: none; } } @@ -273,6 +306,7 @@ .chat-text :where(a) { color: var(--link); text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 45%, transparent); text-underline-offset: 2px; } @@ -493,19 +527,40 @@ :is(.chat-text, .sidebar-markdown) .markdown-external-image { display: flex; + box-sizing: border-box; align-items: center; + justify-content: space-between; flex-wrap: wrap; gap: 0.35em 0.75em; - width: fit-content; + width: 100%; max-width: 100%; margin-top: 0.75em; - padding: 0.5em 0.65em; - border: 1px dashed var(--border-strong); + padding: 0.7em 0.8em; + border: 0; border-radius: var(--radius-md); background: var(--bg-muted); color: var(--muted); } +:is(.chat-text, .sidebar-markdown) .markdown-external-image > a { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 3px 9px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--text) 8%, transparent); + color: var(--link); + font-size: var(--control-ui-text-sm); + font-weight: 550; + text-decoration: none; +} + +:is(.chat-text, .sidebar-markdown) .markdown-external-image > a:hover { + background: color-mix(in srgb, var(--text) 13%, transparent); + color: var(--link-hover); + text-decoration: none; +} + /* Code surfaces must lift off the host bubble in every theme. --secondary equals --card on every dark palette, so chips painted with it vanished inside user bubbles; --bg-muted/--border-strong separate in both modes without an override. */ @@ -563,33 +618,26 @@ } .chat-text :where(blockquote) { - border-left: 2px solid var(--border-strong); + position: relative; + border-inline-start: 0; margin-left: 0; color: var(--muted); - background: rgba(255, 255, 255, 0.02); + background: transparent; padding: 8px 12px; - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} + +.chat-text :where(blockquote)::before { + position: absolute; + content: ""; + inset-block: 4px; + inset-inline-start: 0; + width: 3px; + border-radius: var(--radius-full); + background: var(--border-strong); } .chat-text :where(blockquote blockquote) { margin-top: 8px; - background: rgba(255, 255, 255, 0.03); -} - -.chat-text :where(blockquote blockquote blockquote) { - background: rgba(255, 255, 255, 0.04); -} - -:root[data-theme-mode="light"] .chat-text :where(blockquote) { - background: rgba(0, 0, 0, 0.03); -} - -:root[data-theme-mode="light"] .chat-text :where(blockquote blockquote) { - background: rgba(0, 0, 0, 0.05); -} - -:root[data-theme-mode="light"] .chat-text :where(blockquote blockquote blockquote) { - background: rgba(0, 0, 0, 0.04); } .chat-text :where(hr) { @@ -607,8 +655,6 @@ } .chat-text[dir="rtl"] :where(blockquote) { - border-left: none; - border-right: 3px solid var(--border); padding-left: 0; padding-right: 1em; }