From d2653303965d125bf3dfc3dae5c65470dd4035cd Mon Sep 17 00:00:00 2001 From: Haitham Assoli Date: Fri, 14 Aug 2026 07:34:11 +0300 Subject: [PATCH 1/5] feat(web): read Arabic markdown blocks right-to-left Chat markdown renders under one inherited direction, so an Arabic paragraph keeps its trailing punctuation on the wrong side and a line that opens in Arabic is laid out from the wrong edge. Tag each text block with dir="auto" from a rehype plugin that runs after the sanitizer, letting the browser's own bidi algorithm pick direction per block from its first strong character. Only the outermost block of a nest is tagged: the auto algorithm skips text inside descendants that carry their own dir, so tagging both leaves the outer one blind and silently LTR. Lists stay untagged for the same reason, so their items keep a direction each. Code fences,
, and table column order are left alone. The physical
paddings and table text-align inside .chat-markdown become logical
properties, and lists are padded on both inline sides so a right-to-left
item has room for the marker on the side it lands on.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 apps/web/src/components/ChatMarkdown.tsx      |  3 +
 apps/web/src/index.css                        | 14 ++--
 apps/web/src/markdown-text-direction.test.tsx | 65 ++++++++++++++++++
 apps/web/src/markdown-text-direction.ts       | 67 +++++++++++++++++++
 4 files changed, 143 insertions(+), 6 deletions(-)
 create mode 100644 apps/web/src/markdown-text-direction.test.tsx
 create mode 100644 apps/web/src/markdown-text-direction.ts

diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index e9390ed0a8aa..ef141b016dad 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -73,6 +73,7 @@ import {
   serializeTableElementToMarkdown,
 } from "../markdown-clipboard";
 import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
+import { rehypeAutoTextDirection } from "../markdown-text-direction";
 import {
   normalizeMarkdownLinkDestination,
   resolveInlineCodeFileLinkMeta,
@@ -180,6 +181,8 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
 const CHAT_MARKDOWN_REHYPE_PLUGINS = [
   rehypeRaw,
   [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
+  // After the sanitizer: `dir` is ours, not the message's, and the schema stays untouched.
+  rehypeAutoTextDirection,
 ] satisfies NonNullable;
 
 /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 6169e1a643ca..290c39c84df6 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -1886,13 +1886,15 @@ label:has(> select#reasoning-effort) select {
   color: var(--muted-foreground);
 }
 
+/* Both inline sides: list items carry dir="auto" individually, so a right-to-left item hangs
+   its marker on the right while its neighbours keep theirs on the left. */
 .chat-markdown ul {
-  padding-left: 1.25rem;
+  padding-inline: 1.25rem;
   list-style-type: disc;
 }
 
 .chat-markdown ol {
-  padding-left: 1.25rem;
+  padding-inline: 1.25rem;
   list-style-type: decimal;
 }
 
@@ -1958,8 +1960,8 @@ label:has(> select#reasoning-effort) select {
 }
 
 .chat-markdown blockquote {
-  border-left: 2px solid var(--border);
-  padding-left: 0.8rem;
+  border-inline-start: 2px solid var(--border);
+  padding-inline-start: 0.8rem;
   color: var(--muted-foreground);
 }
 
@@ -1973,7 +1975,7 @@ label:has(> select#reasoning-effort) select {
 
 .chat-markdown section[data-footnotes] ol {
   margin: 0;
-  padding-left: 1.25rem;
+  padding-inline: 1.25rem;
 }
 
 .chat-markdown section[data-footnotes] li + li {
@@ -2138,7 +2140,7 @@ label:has(> select#reasoning-effort) select {
 .chat-markdown th,
 .chat-markdown td {
   padding: 0.45rem 0.75rem;
-  text-align: left;
+  text-align: start;
 }
 
 .chat-markdown thead th {
diff --git a/apps/web/src/markdown-text-direction.test.tsx b/apps/web/src/markdown-text-direction.test.tsx
new file mode 100644
index 000000000000..cb3cb99600ae
--- /dev/null
+++ b/apps/web/src/markdown-text-direction.test.tsx
@@ -0,0 +1,65 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { describe, expect, it } from "vite-plus/test";
+
+import { rehypeAutoTextDirection } from "./markdown-text-direction";
+
+function renderMarkdown(markdown: string): string {
+  return renderToStaticMarkup(
+    
+      {markdown}
+    ,
+  );
+}
+
+describe("rehypeAutoTextDirection", () => {
+  it("marks each text block so the browser reads its own direction", () => {
+    const html = renderMarkdown(`# مرحبا
+
+مرحبا بالعالم
+
+Hello world
+
+- عنصر
+- item`);
+
+    expect(html).toContain('

مرحبا

'); + expect(html).toContain('

مرحبا بالعالم

'); + expect(html).toContain('

Hello world

'); + expect(html).toContain('
  • عنصر
  • '); + expect(html).toContain('
  • item
  • '); + }); + + it("tags only the outermost block, since auto skips dir-bearing descendants", () => { + const html = renderMarkdown(`> مرحبا بالعالم + +- عنصر + + فقرة ثانية`); + + // The quote reads its own direction; a tagged paragraph inside would blind it. + expect(html).toContain('
    '); + expect(html).toContain("

    مرحبا بالعالم

    "); + // Lists stay untagged so each item keeps a direction of its own. + expect(html).toContain("
      "); + expect(html).toContain('
    • '); + expect(html).toContain("

      عنصر

      "); + }); + + it("leaves code and layout containers alone", () => { + const html = renderMarkdown(`\`\`\`ts +const مرحبا = 1; +\`\`\` + +| a | ب | +| - | - | +| 1 | ٢ |`); + + expect(html).toContain("
      ب');
      +    expect(html).toContain('٢');
      +  });
      +});
      diff --git a/apps/web/src/markdown-text-direction.ts b/apps/web/src/markdown-text-direction.ts
      new file mode 100644
      index 000000000000..24cf2e6f628c
      --- /dev/null
      +++ b/apps/web/src/markdown-text-direction.ts
      @@ -0,0 +1,67 @@
      +/**
      + * Mixed Arabic/English markdown reads wrong under a single LTR direction: an Arabic paragraph
      + * keeps its trailing punctuation on the left, and a line that opens in Arabic is laid out from
      + * the wrong edge. `dir="auto"` hands each text block to the browser's own bidi algorithm, which
      + * picks direction from the block's first strong character — per block, so an Arabic paragraph
      + * and the English one under it each get their own.
      + *
      + * Text blocks only. Code and layout containers stay as they are: reversing a `
      ` or a
      + * table's column order changes meaning rather than presentation.
      + */
      +
      +interface HastNode {
      +  type?: string;
      +  tagName?: string;
      +  properties?: Record;
      +  children?: HastNode[];
      +}
      +
      +const AUTO_DIRECTION_TAGS = new Set([
      +  "p",
      +  "li",
      +  "blockquote",
      +  "h1",
      +  "h2",
      +  "h3",
      +  "h4",
      +  "h5",
      +  "h6",
      +  "td",
      +  "th",
      +  "dd",
      +  "dt",
      +  "summary",
      +  "figcaption",
      +]);
      +
      +/**
      + * The auto algorithm skips text inside descendants that carry their own `dir`, so tagging a
      + * block and its children leaves the outer one with nothing to read and it silently falls back
      + * to LTR — a quote whose paragraph is Arabic would keep its rule on the left. Only the
      + * outermost block of a nest gets tagged; everything under it inherits.
      + *
      + * `ul`/`ol` are deliberately absent so their items stay the outermost blocks and keep a
      + * direction each. That leaves the list itself LTR, which is why the stylesheet pads both of
      + * its inline sides: a right-to-left item's marker needs room on the side it lands on.
      + */
      +function isAutoDirectionBlock(node: HastNode): boolean {
      +  if (node.type !== "element" || !node.tagName) return false;
      +  // A GitHub alert renders as a titled callout whose title is always English, so reading a
      +  // direction off the quote as a whole would pin every alert to LTR. Its paragraphs are left
      +  // to speak for themselves.
      +  if (node.properties?.dataAlert != null) return false;
      +  return AUTO_DIRECTION_TAGS.has(node.tagName);
      +}
      +
      +export function rehypeAutoTextDirection() {
      +  return (tree: HastNode) => {
      +    const visit = (node: HastNode, insideTaggedBlock: boolean) => {
      +      const tag = !insideTaggedBlock && isAutoDirectionBlock(node);
      +      if (tag) {
      +        node.properties = { ...node.properties, dir: "auto" };
      +      }
      +      node.children?.forEach((child) => visit(child, insideTaggedBlock || tag));
      +    };
      +    visit(tree, false);
      +  };
      +}
      
      From 98e277fd3dbf33f355e75abe4488d33ad08d8e58 Mon Sep 17 00:00:00 2001
      From: Haitham Assoli 
      Date: Fri, 14 Aug 2026 07:54:26 +0300
      Subject: [PATCH 2/5] feat(mobile): mirror markdown list markers and quote
       rails for Arabic
      MIME-Version: 1.0
      Content-Type: text/plain; charset=UTF-8
      Content-Transfer-Encoding: 8bit
      
      The mobile clients had the same problem the web client did: a list of
      Arabic items kept its bullets on the left, opposite the text they label,
      and an Arabic quote kept its rule on the left.
      
      React Native has no dir="auto" — Yoga will not mirror a row or resolve
      start/end padding without an explicit direction — so the first-strong
      character rule the browser applies for free is applied by hand here, per
      block, matching the web client. The text itself needs no help: iOS
      resolves natural alignment from the paragraph's own base writing
      direction and Android's default text direction is first-strong.
      
      Applied in the native iOS renderer for blockquotes and list items, and
      in the list renderer the other platforms fall back to. The physical
      paddings and borders those two touch become logical, so they follow.
      
      Co-Authored-By: Claude Opus 5 (1M context) 
      ---
       apps/mobile/modules/t3-markdown-text/index.ts |  1 +
       .../modules/t3-markdown-text/package.json     |  1 +
       .../src/NativeMarkdownBlock.ios.tsx           | 18 ++++++--
       .../src/markdownTextDirection.test.ts         | 32 +++++++++++++
       .../src/markdownTextDirection.ts              | 46 +++++++++++++++++++
       .../src/features/threads/ThreadFeed.tsx       | 15 ++++--
       6 files changed, 105 insertions(+), 8 deletions(-)
       create mode 100644 apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.test.ts
       create mode 100644 apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.ts
      
      diff --git a/apps/mobile/modules/t3-markdown-text/index.ts b/apps/mobile/modules/t3-markdown-text/index.ts
      index 89bce5395c8c..3f58ff2e3b45 100644
      --- a/apps/mobile/modules/t3-markdown-text/index.ts
      +++ b/apps/mobile/modules/t3-markdown-text/index.ts
      @@ -14,6 +14,7 @@ export {
         type NativeMarkdownDocumentChunk,
         type NativeMarkdownTextRun,
       } from "./src/nativeMarkdownText";
      +export { markdownNodeDirection, markdownTextDirection } from "./src/markdownTextDirection";
       export { MarkdownTextPrimitive } from "./src/MarkdownTextPrimitive";
       export {
         SelectableMarkdownText,
      diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json
      index d51b6c5d9ff7..706528aad53b 100644
      --- a/apps/mobile/modules/t3-markdown-text/package.json
      +++ b/apps/mobile/modules/t3-markdown-text/package.json
      @@ -18,6 +18,7 @@
         "react-native": "./index.ts",
         "exports": {
           ".": "./index.ts",
      +    "./direction": "./src/markdownTextDirection.ts",
           "./file-icons": "./src/markdownFileIcons.ts",
           "./links": "./src/markdownLinks.ts",
           "./markdown": "./src/nativeMarkdownText.ts",
      diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
      index 5fbe6d4dff44..1f44a452b8b5 100644
      --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
      +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
      @@ -3,6 +3,7 @@ import { Image, ScrollView, Text, useColorScheme, View } from "react-native";
       import type { MarkdownNode } from "react-native-nitro-markdown/headless";
       
       import { CopyTextButton } from "./CopyTextButton";
      +import { markdownNodeDirection } from "./markdownTextDirection";
       import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
       import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText";
       import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
      @@ -510,13 +511,19 @@ function NativeList(props: {
               return (
                 
                    {
      +  it("reads the first letter, ignoring markers and punctuation before it", () => {
      +    expect(markdownTextDirection("مرحبا بالعالم")).toBe("rtl");
      +    expect(markdownTextDirection("  — «مرحبا»")).toBe("rtl");
      +    expect(markdownTextDirection("שלום עולם")).toBe("rtl");
      +    expect(markdownTextDirection("Hello world")).toBeUndefined();
      +    expect(markdownTextDirection("1. Hello, مرحبا")).toBeUndefined();
      +    expect(markdownTextDirection("42 — 3.14")).toBeUndefined();
      +    expect(markdownTextDirection("")).toBeUndefined();
      +  });
      +});
      +
      +describe("markdownNodeDirection", () => {
      +  const node = (type: string, children: MarkdownNode[]): MarkdownNode =>
      +    ({ type, children }) as MarkdownNode;
      +  const text = (content: string): MarkdownNode => ({ type: "text", content }) as MarkdownNode;
      +
      +  it("descends to the first child that actually reads as text", () => {
      +    expect(
      +      markdownNodeDirection(node("list_item", [node("paragraph", [text("  "), text("مرحبا")])])),
      +    ).toBe("rtl");
      +    expect(markdownNodeDirection(node("list_item", [node("paragraph", [text("item")])]))).toBe(
      +      undefined,
      +    );
      +    expect(markdownNodeDirection(node("list_item", []))).toBeUndefined();
      +  });
      +});
      diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.ts b/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.ts
      new file mode 100644
      index 000000000000..3893da117f2c
      --- /dev/null
      +++ b/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.ts
      @@ -0,0 +1,46 @@
      +/**
      + * The web client hands each markdown block to the browser with `dir="auto"` and lets it pick a
      + * direction from the first strong character. React Native has no equivalent: Yoga will not
      + * mirror a row or resolve start/end padding without an explicit `direction`, so the same rule is
      + * applied here by hand, per block, to keep the two clients reading alike.
      + *
      + * Text itself needs no help — iOS resolves natural alignment from the paragraph's own base
      + * writing direction and Android's default text direction is first-strong. This is only for the
      + * chrome around the text: a list marker and a quote rail have to be told which side they are on.
      + */
      +
      +import type { MarkdownNode } from "react-native-nitro-markdown/headless";
      +
      +const FIRST_LETTER = /\p{L}/u;
      +
      +/**
      + * The living right-to-left scripts, which is where the first-strong rule earns its keep. Not the
      + * full Unicode R/AL bidi classes — those are not expressible as a property escape in JS — but
      + * every script a message is realistically written in.
      + */
      +const RIGHT_TO_LEFT_LETTER =
      +  /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Adlam}]/u;
      +
      +/** `undefined` rather than `"ltr"` so a left-to-right block simply inherits and nothing moves. */
      +export function markdownTextDirection(text: string): "rtl" | undefined {
      +  const firstLetter = FIRST_LETTER.exec(text)?.[0];
      +  return firstLetter && RIGHT_TO_LEFT_LETTER.test(firstLetter) ? "rtl" : undefined;
      +}
      +
      +export function markdownNodeDirection(node: MarkdownNode): "rtl" | undefined {
      +  return markdownTextDirection(markdownNodeText(node));
      +}
      +
      +function markdownNodeText(node: MarkdownNode): string {
      +  if (node.content !== undefined) {
      +    return node.content;
      +  }
      +  // Stops at the first block that reads as text; a marker's own direction comes from its item.
      +  for (const child of node.children ?? []) {
      +    const text = markdownNodeText(child);
      +    if (FIRST_LETTER.test(text)) {
      +      return text;
      +    }
      +  }
      +  return "";
      +}
      diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
      index 874c31249ce7..f65b94812700 100644
      --- a/apps/mobile/src/features/threads/ThreadFeed.tsx
      +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
      @@ -82,6 +82,7 @@ import {
       import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
       import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
       import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
      +import { markdownNodeDirection } from "@t3tools/mobile-markdown-text/direction";
       import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
       import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
       import {
      @@ -631,17 +632,25 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
                       
                     );
                   }
      +            // Per item: a right-to-left item mirrors its own row so the marker lands on the side
      +            // its text starts from. Matches the web client's per-block direction.
      +            const itemDirection = markdownNodeDirection(child);
                   return (
      -              
      +              
                       
                         {ordered ? `${start + index}.` : "•"}
      
      From 4a59ff35ffe5f389892f269befb64c1134a36d98 Mon Sep 17 00:00:00 2001
      From: Haitham Assoli 
      Date: Sun, 16 Aug 2026 11:25:54 +0300
      Subject: [PATCH 3/5] fix(bidi): address review on right-to-left markdown
      MIME-Version: 1.0
      Content-Type: text/plain; charset=UTF-8
      Content-Transfer-Encoding: 8bit
      
      - Run the direction plugin for user messages too: it only sat in the raw-HTML
        rehype array, and every user bubble renders with parseRawHtml={false}, so
        Arabic typed by the user — the case the change is for — never got dir="auto".
      - Give the details trigger its own dir: MarkdownDetails drops the 
        element and re-renders only its children, taking the plugin's dir with it.
      - Let a nested list item read its own direction. A list is a container, not a
        text block, so its items restart the outermost-only rule.
      - Pad only the end side of lists that hold a right-to-left item, rather than
        both sides of every list, which inset English lists from the text around
        them. The plugin marks those lists: :dir(rtl) reads well but the CSS build
        lowers it to a :lang() list that never matches a dir="auto" element.
      - Pin code and tables to ltr on both clients. They are left untagged on
        purpose, but an Arabic block around one still handed it a direction to
        inherit, mirroring a code header or reversing columns.
      - Mobile: return "ltr" rather than undefined for a left-to-right block, since
        Yoga inherits direction and an English item under an Arabic one kept the
        mirrored chrome; add the four remaining right-to-left scripts; and wrap task
        list items, the one row in the fallback renderer that never mirrored.
      
      Co-Authored-By: Claude Opus 5 (1M context) 
      ---
       .../src/NativeMarkdownBlock.ios.tsx           | 30 +++++++-----
       .../src/markdownTextDirection.test.ts         |  8 +--
       .../src/markdownTextDirection.ts              | 15 ++++--
       .../src/features/threads/ThreadFeed.tsx       | 12 +++--
       apps/web/src/components/ChatMarkdown.tsx      | 14 +++++-
       apps/web/src/index.css                        | 22 +++++++--
       apps/web/src/markdown-text-direction.test.tsx | 23 ++++++++-
       apps/web/src/markdown-text-direction.ts       | 49 +++++++++++++++++--
       8 files changed, 137 insertions(+), 36 deletions(-)
      
      diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
      index 1f44a452b8b5..64f01d99f1ea 100644
      --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
      +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
      @@ -589,23 +589,29 @@ export function NativeMarkdownBlock(props: {
                 ))}
               
             );
      +    // Code and tables read in source order, so they say so rather than inheriting: an Arabic list
      +    // item or quote around one would otherwise mirror its header row and its columns.
           case "code_block":
             return (
      -        
      +        
      +          
      +        
             );
           case "table":
             return (
      -        
      +        
      +          
      +        
             );
           case "image":
             return (
      diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.test.ts b/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.test.ts
      index f4599daa2193..e7672084cf53 100644
      --- a/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.test.ts
      +++ b/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.test.ts
      @@ -8,8 +8,10 @@ describe("markdownTextDirection", () => {
           expect(markdownTextDirection("مرحبا بالعالم")).toBe("rtl");
           expect(markdownTextDirection("  — «مرحبا»")).toBe("rtl");
           expect(markdownTextDirection("שלום עולם")).toBe("rtl");
      -    expect(markdownTextDirection("Hello world")).toBeUndefined();
      -    expect(markdownTextDirection("1. Hello, مرحبا")).toBeUndefined();
      +    expect(markdownTextDirection("ࡀࡁࡂ")).toBe("rtl");
      +    expect(markdownTextDirection("Hello world")).toBe("ltr");
      +    expect(markdownTextDirection("1. Hello, مرحبا")).toBe("ltr");
      +    // No letter to read: left to inherit rather than forced either way round.
           expect(markdownTextDirection("42 — 3.14")).toBeUndefined();
           expect(markdownTextDirection("")).toBeUndefined();
         });
      @@ -25,7 +27,7 @@ describe("markdownNodeDirection", () => {
             markdownNodeDirection(node("list_item", [node("paragraph", [text("  "), text("مرحبا")])])),
           ).toBe("rtl");
           expect(markdownNodeDirection(node("list_item", [node("paragraph", [text("item")])]))).toBe(
      -      undefined,
      +      "ltr",
           );
           expect(markdownNodeDirection(node("list_item", []))).toBeUndefined();
         });
      diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.ts b/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.ts
      index 3893da117f2c..8d91e000c33b 100644
      --- a/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.ts
      +++ b/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.ts
      @@ -19,15 +19,20 @@ const FIRST_LETTER = /\p{L}/u;
        * every script a message is realistically written in.
        */
       const RIGHT_TO_LEFT_LETTER =
      -  /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Adlam}]/u;
      +  /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Adlam}\p{Script=Samaritan}\p{Script=Mandaic}\p{Script=Hanifi_Rohingya}\p{Script=Yezidi}]/u;
       
      -/** `undefined` rather than `"ltr"` so a left-to-right block simply inherits and nothing moves. */
      -export function markdownTextDirection(text: string): "rtl" | undefined {
      +/**
      + * A left-to-right block says so rather than staying silent: Yoga inherits `direction`, so an
      + * English item under an Arabic one would otherwise keep its parent's mirrored chrome. Only a
      + * block with no letter at all — a bare number, a lone link — is left to inherit.
      + */
      +export function markdownTextDirection(text: string): "ltr" | "rtl" | undefined {
         const firstLetter = FIRST_LETTER.exec(text)?.[0];
      -  return firstLetter && RIGHT_TO_LEFT_LETTER.test(firstLetter) ? "rtl" : undefined;
      +  if (!firstLetter) return undefined;
      +  return RIGHT_TO_LEFT_LETTER.test(firstLetter) ? "rtl" : "ltr";
       }
       
      -export function markdownNodeDirection(node: MarkdownNode): "rtl" | undefined {
      +export function markdownNodeDirection(node: MarkdownNode): "ltr" | "rtl" | undefined {
         return markdownTextDirection(markdownNodeText(node));
       }
       
      diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
      index 8a9ae59827d3..722a34c34615 100644
      --- a/apps/mobile/src/features/threads/ThreadFeed.tsx
      +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
      @@ -586,14 +586,18 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
               
                 {node.children?.map((child, index) => {
                   const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`;
      +            // Per item: a right-to-left item mirrors its own row so the marker lands on the side
      +            // its text starts from. Matches the web client's per-block direction.
      +            const itemDirection = markdownNodeDirection(child);
                   if (child.type === "task_list_item") {
      +              // Its checkbox row is the renderer's own, so the direction goes on a wrapper —
      +              // without it a task item is the one row in the list that never mirrors.
                     return (
      -                
      +                
      +                  
      +                
                     );
                   }
      -            // Per item: a right-to-left item mirrors its own row so the marker lands on the side
      -            // its text starts from. Matches the web client's per-block direction.
      -            const itemDirection = markdownNodeDirection(child);
                   return (
                     ;
       
      +/** User messages render without raw HTML, and their Arabic needs a direction just the same. */
      +const CHAT_MARKDOWN_TEXT_DIRECTION_PLUGINS = [
      +  rehypeAutoTextDirection,
      +] satisfies NonNullable;
      +
       /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */
       const GITHUB_ALERT_PRESENTATIONS: Record<
         string,
      @@ -556,7 +561,10 @@ function MarkdownDetails({
             data-markdown-details-open={isOpen ? "true" : "false"}
           >
             
               ');
           expect(html).toContain("

      مرحبا بالعالم

      "); - // Lists stay untagged so each item keeps a direction of its own. - expect(html).toContain("
        "); + // Lists never take a `dir` of their own, so each item keeps one. + expect(html).not.toContain("
          '); expect(html).toContain("

          عنصر

          "); }); + it("lets a nested list item read its own direction, not its parent's", () => { + const html = renderMarkdown(`- عنصر عربي + - English child`); + + expect(html).toContain('
        • عنصر عربي'); + expect(html).toContain('
        • English child
        • '); + }); + + it("marks only the lists that hold a right-to-left item, so the marker has a gutter", () => { + expect(renderMarkdown("- عنصر\n- item")).toContain('
            '); + expect(renderMarkdown("1. عنصر")).toContain('
              '); + // An all-English list keeps the end side flush with the text around it. + expect(renderMarkdown("- item\n- another")).toContain("
                "); + // Only the nested list carries the Arabic item, so only it gets the gutter. + const nested = renderMarkdown("- item\n - عنصر"); + expect(nested).toContain('
                  \n
                • item'); + expect(nested).toContain('
                    '); + }); + it("leaves code and layout containers alone", () => { const html = renderMarkdown(`\`\`\`ts const مرحبا = 1; diff --git a/apps/web/src/markdown-text-direction.ts b/apps/web/src/markdown-text-direction.ts index 24cf2e6f628c..14a6faa08c80 100644 --- a/apps/web/src/markdown-text-direction.ts +++ b/apps/web/src/markdown-text-direction.ts @@ -12,6 +12,7 @@ interface HastNode { type?: string; tagName?: string; + value?: string; properties?: Record; children?: HastNode[]; } @@ -41,8 +42,7 @@ const AUTO_DIRECTION_TAGS = new Set([ * outermost block of a nest gets tagged; everything under it inherits. * * `ul`/`ol` are deliberately absent so their items stay the outermost blocks and keep a - * direction each. That leaves the list itself LTR, which is why the stylesheet pads both of - * its inline sides: a right-to-left item's marker needs room on the side it lands on. + * direction each. That leaves the list itself LTR, which is what `data-rtl-item` below is for. */ function isAutoDirectionBlock(node: HastNode): boolean { if (node.type !== "element" || !node.tagName) return false; @@ -53,6 +53,41 @@ function isAutoDirectionBlock(node: HastNode): boolean { return AUTO_DIRECTION_TAGS.has(node.tagName); } +const FIRST_LETTER = /\p{L}/u; + +/** + * The living right-to-left scripts. Not the full Unicode R/AL bidi classes — those are not + * expressible as a property escape in JS — but every script a message is realistically in. + * Kept in step with the mobile client's `markdownTextDirection`, which reads the same rule. + */ +const RIGHT_TO_LEFT_LETTER = + /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Adlam}\p{Script=Samaritan}\p{Script=Mandaic}\p{Script=Hanifi_Rohingya}\p{Script=Yezidi}]/u; + +function firstLetter(node: HastNode): string | undefined { + if (node.type === "text") return FIRST_LETTER.exec(node.value ?? "")?.[0]; + for (const child of node.children ?? []) { + const letter = firstLetter(child); + if (letter) return letter; + } + return undefined; +} + +/** + * A right-to-left item hangs its marker on the list's end side, where an LTR list has no gutter, + * so the list has to be told it holds one. `:dir(rtl)` would say it in CSS alone, but the build + * lowers that selector to a `:lang()` list which never matches a `dir="auto"` element. + * + * The first strong letter is the same rule `dir="auto"` itself follows, so the two agree; where + * they don't, the cost is 1.25rem of unused padding rather than text on the wrong side. + */ +function holdsRightToLeftItem(list: HastNode): boolean { + return (list.children ?? []).some((child) => { + if (child.tagName !== "li") return false; + const letter = firstLetter(child); + return letter != null && RIGHT_TO_LEFT_LETTER.test(letter); + }); +} + export function rehypeAutoTextDirection() { return (tree: HastNode) => { const visit = (node: HastNode, insideTaggedBlock: boolean) => { @@ -60,7 +95,15 @@ export function rehypeAutoTextDirection() { if (tag) { node.properties = { ...node.properties, dir: "auto" }; } - node.children?.forEach((child) => visit(child, insideTaggedBlock || tag)); + const isList = node.tagName === "ul" || node.tagName === "ol"; + if (isList && holdsRightToLeftItem(node)) { + node.properties = { ...node.properties, dataRtlItem: "" }; + } + // A list is a container, not a text block, so its items start the outermost-only rule over: + // an English item nested under an Arabic one still reads its own way round. The item above + // it keeps a direction of its own — the auto algorithm reads the text it holds directly, + // which a nested list never was. + node.children?.forEach((child) => visit(child, !isList && (insideTaggedBlock || tag))); }; visit(tree, false); }; From 4ccc31977a00fa730ded8436512b00bbb508632a Mon Sep 17 00:00:00 2001 From: Haitham Assoli Date: Sun, 16 Aug 2026 11:40:15 +0300 Subject: [PATCH 4/5] fix(bidi): keep lists, tables and inline code out of the inherited direction - Pin `ul`/`ol` to ltr. The gutter rules read the list box as left-to-right and nothing kept it that way: a list nested in an Arabic item or quote inherited the direction and swapped the gutter to the side its markers aren't on. - Isolate inline code. A Latin run in a right-to-left paragraph has its bounding neutrals reordered, so `--flag` read `flag--` and `foo()` read `()foo`. Only paragraphs that resolve RTL were affected, which is new here. - Move the table's ltr onto `.chat-markdown-table-container`. The scroller is the ScrollArea viewport inside it, so pinning the table alone still left an overflowing table opening on its last column with its fades flipped. - Restrict the nested-list rule to lists under an item. A quote's whole content can be a list, and tagging those items left `dir="auto"` on the quote with nothing to read, pinning the rail left while the items read right. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/index.css | 24 +++++++++++++++---- apps/web/src/markdown-text-direction.test.tsx | 8 +++++++ apps/web/src/markdown-text-direction.ts | 18 +++++++------- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3f0ec7a29096..6d76e123b2e0 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1598,6 +1598,11 @@ code { .chat-markdown blockquote, .chat-markdown pre, .chat-markdown .chat-markdown-table-container { + /* The whole container, not just the table: the scroller is the ScrollArea viewport inside it, + and an inherited right-to-left direction would open it on its last column and flip its + overflow fades. Column order is meaning here, so it doesn't mirror. Cells keep their own + dir="auto", so the text in each still reads its own way round. */ + direction: ltr; margin: 0.65rem 0; } @@ -1635,6 +1640,14 @@ code { color: var(--muted-foreground); } +/* The gutter rules below read the list box as left-to-right, and nothing else would keep it + that way: a list nested in an Arabic item or quote inherits its direction, which would swap + the gutter to the side the markers aren't on. Items carry their own dir and are unaffected. */ +.chat-markdown ul, +.chat-markdown ol { + direction: ltr; +} + .chat-markdown ul { /* Reset for nested uls under a widened ol — --list-gutter is an inherited custom property, so without this a task-list under a 3+ digit ordered @@ -1750,6 +1763,11 @@ code { } .chat-markdown :not(pre) > code { + /* Isolated, not just pinned: inside a right-to-left paragraph the bidi algorithm reorders the + neutrals bounding a Latin run with the text around it, so `--flag` reads `flag--` and + `foo()` reads `()foo`. Isolation keeps the span a run of its own; ltr is its source order. */ + direction: ltr; + unicode-bidi: isolate; border: 1px solid var(--border); border-radius: 0.375rem; background: var(--muted); @@ -1770,7 +1788,7 @@ code { } .chat-markdown pre { - /* Same as the table: code reads in source order, whatever direction the block around it took. */ + /* Code reads in source order, whatever direction the block around it resolved to. */ direction: ltr; max-width: 100%; overflow-x: auto; @@ -1833,10 +1851,6 @@ code { characters and defeat the overflow — restore word-boundary wrapping so the min column width is the longest word. */ .chat-markdown table { - /* Left untagged on purpose, but an Arabic list item or quote around it would still hand it a - right-to-left direction to inherit, and column order is meaning here rather than presentation. - Cells keep dir="auto", so the text inside each still reads its own way round. */ - direction: ltr; width: 100%; min-width: max-content; border-collapse: collapse; diff --git a/apps/web/src/markdown-text-direction.test.tsx b/apps/web/src/markdown-text-direction.test.tsx index c6ff0aa3d82d..7e83c6a47c21 100644 --- a/apps/web/src/markdown-text-direction.test.tsx +++ b/apps/web/src/markdown-text-direction.test.tsx @@ -55,6 +55,14 @@ Hello world expect(html).toContain('
                  • English child
                  • '); }); + it("keeps a list-only quote's items untagged, so the rail still has text to read", () => { + const html = renderMarkdown(`> - عنصر +> - عنصر آخر`); + + expect(html).toContain('
                    '); + expect(html).not.toContain("
                  • { expect(renderMarkdown("- عنصر\n- item")).toContain('
                      '); expect(renderMarkdown("1. عنصر")).toContain('
                        '); diff --git a/apps/web/src/markdown-text-direction.ts b/apps/web/src/markdown-text-direction.ts index 14a6faa08c80..1f0d11350fd4 100644 --- a/apps/web/src/markdown-text-direction.ts +++ b/apps/web/src/markdown-text-direction.ts @@ -90,8 +90,8 @@ function holdsRightToLeftItem(list: HastNode): boolean { export function rehypeAutoTextDirection() { return (tree: HastNode) => { - const visit = (node: HastNode, insideTaggedBlock: boolean) => { - const tag = !insideTaggedBlock && isAutoDirectionBlock(node); + const visit = (node: HastNode, taggedAncestor: string | undefined) => { + const tag = taggedAncestor === undefined && isAutoDirectionBlock(node); if (tag) { node.properties = { ...node.properties, dir: "auto" }; } @@ -99,12 +99,14 @@ export function rehypeAutoTextDirection() { if (isList && holdsRightToLeftItem(node)) { node.properties = { ...node.properties, dataRtlItem: "" }; } - // A list is a container, not a text block, so its items start the outermost-only rule over: - // an English item nested under an Arabic one still reads its own way round. The item above - // it keeps a direction of its own — the auto algorithm reads the text it holds directly, - // which a nested list never was. - node.children?.forEach((child) => visit(child, !isList && (insideTaggedBlock || tag))); + // A list under an item starts the outermost-only rule over, so an English item nested under + // an Arabic one still reads its own way round: the item above it keeps its own line for + // `auto` to read. A list under a quote does not get that — a quote's whole content can be + // the list, and tagging the items would leave the rail nothing to read and pin it left. + const childAncestor = + isList && taggedAncestor === "li" ? undefined : tag ? node.tagName : taggedAncestor; + node.children?.forEach((child) => visit(child, childAncestor)); }; - visit(tree, false); + visit(tree, undefined); }; } From 68acb73f8a3b64114e3c581c184996ce9c4b87f6 Mon Sep 17 00:00:00 2001 From: Haitham Assoli Date: Sun, 16 Aug 2026 11:47:42 +0300 Subject: [PATCH 5/5] fix(bidi): stop pinning paragraphs and quotes to a left-to-right base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table container's `direction: ltr` went into the shared margin rule, whose selector list also holds `p` and `blockquote` — the two blocks the plugin tags with dir="auto". An author `direction` outranks the UA rule that `auto` resolves through, so every Arabic paragraph read left-to-right again and the quote rail stayed left, undoing what the branch is for. It gets its own rule. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/index.css | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6d76e123b2e0..c783a585f0ba 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1598,14 +1598,21 @@ code { .chat-markdown blockquote, .chat-markdown pre, .chat-markdown .chat-markdown-table-container { - /* The whole container, not just the table: the scroller is the ScrollArea viewport inside it, - and an inherited right-to-left direction would open it on its last column and flip its - overflow fades. Column order is meaning here, so it doesn't mirror. Cells keep their own - dir="auto", so the text in each still reads its own way round. */ - direction: ltr; margin: 0.65rem 0; } +/* The whole container, not just the table: the scroller is the ScrollArea viewport inside it, + and an inherited right-to-left direction would open it on its last column and flip its + overflow fades. Column order is meaning here, so it doesn't mirror. Cells keep their own + dir="auto", so the text in each still reads its own way round. + + On its own rule, not the shared margin one above: that selector list also holds `p` and + `blockquote`, and an author `direction` outranks the UA rule `dir="auto"` resolves through, + so pinning it there would send every Arabic paragraph and quote back to a left-to-right base. */ +.chat-markdown .chat-markdown-table-container { + direction: ltr; +} + .chat-markdown h1, .chat-markdown h2, .chat-markdown h3,