fix(desktop): list markers and quote border follow RTL message direction

unicode-bidi:plaintext (#44596) resolves text direction per line, but
list markers and the blockquote border are box chrome driven by the CSS
direction property, which plaintext never sets, so an RTL list renders
its numbers stranded at the far left edge. CSS cannot close this gap
(:dir() only reads the dir attribute, never plaintext resolution), so
ul/ol/blockquote carry dir="auto" and the browser resolves their box
direction natively while the plaintext rules keep owning the text.
Inline code carries dir="ltr", which HTML's auto algorithm skips,
matching the no-vote contract the CSS isolate already gives it.
This commit is contained in:
Adolanium 2026-06-12 06:20:01 +03:00 committed by Teknium
parent fc1119ca66
commit f4100f4394
2 changed files with 150 additions and 3 deletions

View file

@ -0,0 +1,129 @@
// Lists and blockquotes have chrome beside the text (markers, the quote
// border) whose side is driven by the box's CSS direction, which the
// unicode-bidi:plaintext rules never touch. These tests pin the split of
// responsibilities: ul/ol/blockquote carry dir="auto" so the browser
// resolves their box direction from content, inline code carries dir="ltr"
// so it neither votes in that resolution nor reorders, and plain prose
// blocks stay attribute-free (the plaintext CSS owns them). jsdom does not
// resolve dir="auto", so the contract is asserted at the attribute level.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { Thread } from './thread'
const createdAt = new Date('2026-06-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'hi' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(text: string): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function Harness({ text }: { text: string }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage(text)],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('block-level direction chrome', () => {
it('lists carry dir="auto" so markers follow the resolved direction', async () => {
render(<Harness text={'מקומות:\n\n1. חוף גורדון\n2. שוק הכרמל\n\n- פריט\n- item'} />)
const item = await screen.findByText(/חוף גורדון/)
expect(item.closest('ol')?.getAttribute('dir')).toBe('auto')
const bullet = await screen.findByText(/פריט/)
expect(bullet.closest('ul')?.getAttribute('dir')).toBe('auto')
})
it('blockquotes carry dir="auto" so the border follows the resolved direction', async () => {
render(<Harness text={'> ציטוט קצר בעברית'} />)
const quote = await screen.findByText(/ציטוט קצר/)
expect(quote.closest('blockquote')?.getAttribute('dir')).toBe('auto')
})
it('inline code carries dir="ltr" so it does not vote in dir="auto" resolution', async () => {
render(<Harness text={'1. `npm install` מתקין תלויות'} />)
const code = await screen.findByText('npm install')
expect(code.tagName).toBe('CODE')
expect(code.getAttribute('dir')).toBe('ltr')
expect(code.closest('ol')?.getAttribute('dir')).toBe('auto')
})
it('plain prose blocks stay attribute-free (plaintext CSS owns them)', async () => {
render(<Harness text={'שלום לכולם'} />)
const paragraph = await screen.findByText(/שלום לכולם/)
expect(paragraph.closest('p')?.hasAttribute('dir')).toBe(false)
})
})

View file

@ -484,19 +484,37 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
),
a: MarkdownLink,
// Inline code must not vote when an ancestor resolves `dir="auto"`
// (HTML's algorithm skips descendants that carry their own dir),
// mirroring the CSS isolate that already keeps it out of the
// plaintext scan. Fenced code never reaches this override; it goes
// through the code plugin's CodeCard path.
inlineCode: ({ className, ...props }: ComponentProps<'code'>) => (
<code className={className} dir="ltr" {...props} />
),
// `---` as quiet spacing, not a heavy full-width rule.
hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />,
// Lists and blockquotes have chrome that sits *beside* the text
// (markers, the quote border), and that side is driven by the CSS
// `direction` of the box, which `unicode-bidi: plaintext` never
// touches — an RTL list otherwise renders its numbers stranded at
// the far left. `dir="auto"` lets the browser resolve the box
// direction from content; the plaintext rules in styles.css keep
// owning per-line text direction. Inline code carries `dir="ltr"`
// (see the `code` override) so it doesn't vote here either, same
// contract as the CSS isolate.
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
<blockquote
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}
dir="auto"
{...props}
/>
),
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
<ul className={cn('my-1 gap-0', className)} {...props} />
<ul className={cn('my-1 gap-0', className)} dir="auto" {...props} />
),
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
<ol className={cn('my-1 gap-0', className)} {...props} />
<ol className={cn('my-1 gap-0', className)} dir="auto" {...props} />
),
li: ({ className, ...props }: ComponentProps<'li'>) => (
<li className={cn('leading-(--dt-line-height)', className)} {...props} />