From edb0c905759faa2a263e0c51e1903a99a220d151 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Tue, 4 Aug 2026 02:36:43 +0300 Subject: [PATCH 1/8] feat: per-block direction detection in Auto mode Response blocks now get dir=rtl/ltr by presence of RTL characters instead of first-strong-character plaintext heuristic. Fixes English-first mixed lines drifting left, bullets on the wrong side, and hard-to-read mixed Hebrew/English text. Pure-English blocks stay naturally left-aligned. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 ++ ...04-auto-mode-per-block-direction-design.md | 67 +++++++++++++++++++ package.json | 2 +- src/content.ts | 55 +++++++++++++-- 4 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-04-auto-mode-per-block-direction-design.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d6afd3f..d13ad96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v0.5.1 + +- **Per-block direction detection (Auto mode)** — response paragraphs previously picked their direction from the *first* character (`unicode-bidi: plaintext`), so a Hebrew answer with a line starting in English drifted left, list bullets flipped to the wrong side, and mixed Hebrew/English lines were hard to read. Auto mode now decides direction per block element (paragraph, list item, heading, blockquote) by *presence* of Hebrew/Arabic/Persian characters: mixed lines align right and read correctly with bullets on the right, while pure-English headings and lines stay naturally left-aligned. Applied live during streaming; code blocks, tool output, and thinking blocks are untouched. + ## v0.5.0 - **New: Force LTR (Always) mode** (`Claude RTL: Force LTR (Always)`) — pins the whole chat (messages, input box, question/permission dialogs, Plan Preview) to left-to-right, even when the conversation contains Hebrew, Arabic, or Persian text. The direction choice is now symmetric: users who want RTL pick an RTL mode, users who prefer a stable LTR layout while chatting in an RTL language pick LTR Always. Shown in the status bar as `LTR: Always` and available from the status-bar menu; survives Claude Code updates via auto-reactivate like the other modes. (Contributed by @moeseif in #19.) diff --git a/docs/superpowers/specs/2026-08-04-auto-mode-per-block-direction-design.md b/docs/superpowers/specs/2026-08-04-auto-mode-per-block-direction-design.md new file mode 100644 index 0000000..52c6d11 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-auto-mode-per-block-direction-design.md @@ -0,0 +1,67 @@ +# Auto Mode: Per-Block Direction Detection + +**Date:** 2026-08-04 +**Status:** Approved +**Scope:** `src/content.ts` only (Auto mode CSS + JS) + +## Problem + +In Auto mode, once a bubble is tagged `.YBYrtl`, response paragraphs get +`unicode-bidi: plaintext`, which sets each paragraph's base direction from its +**first strong character**. In a Hebrew conversation this causes: + +1. Paragraphs that start with an English word flip to LTR and align left. +2. List markers (bullets/dots) follow that direction and land on the wrong side. +3. Mixed Hebrew/English lines scramble punctuation and word order, hard to read. + +## Decision + +Direction is decided **per block element** by *presence* of RTL characters, not +by first character: + +- Block **contains** Hebrew/Arabic/Persian → `dir="rtl"` → right-aligned, + bullet on right, embedded English isolated inline. +- Block has **no** RTL characters (pure English heading, free-standing code-ish + line) → `dir="ltr"` → left-aligned, natural. + +User-approved trade-off: a lone English word on its own line goes left; a Hebrew +paragraph containing lots of English still goes right. + +## Mechanism + +### JS (extends the existing Auto-mode observer in `RTL_AUTO_JS_CODE`) + +- For each `.YBYrtl` bubble, walk block elements — `p`, `li`, `h1`–`h6`, + `blockquote` — inside markdown containers. +- Skip anything inside `pre`, `code`, `[class*="codeBlockWrapper_"]`, tool + output, and thinking blocks (reuse the stripper's skip approach). +- Set `dir="rtl"` or `dir="ltr"` per the RTL-char test + (`/[֐-׿؀-ۿݐ-ݿﭐ-﷿ﹰ-﻾]/`). +- Re-scan on mutations, debounced — same pattern as the BiDi stripper — so a + streamed line that starts in English flips right once Hebrew arrives. + +### CSS (in `AUTO_RTL_RULES`) + +- Remove `unicode-bidi: plaintext` and the blanket `text-align: right` from + response-paragraph rules. +- Add: + - `[dir="rtl"]` blocks → `direction: rtl; text-align: right; unicode-bidi: isolate`. + - `[dir="ltr"]` blocks → `direction: ltr; text-align: left`. +- Bubble-level `direction: rtl` layout rules stay (bubble alignment unchanged). +- Prompt input rules stay as-is (first-char live detection is correct while + typing). + +## Out of Scope + +- Active / Always / LTR-Always modes (their semantics are explicit, not + detected). +- Plan Preview. +- Input field. + +## Verification + +- `npm run build` clean; `npm test` (concurrency) still passes. +- Manual: in Auto mode with a mixed Hebrew/English response confirm — + English-first mixed line right-aligned and readable, bullets on the right, + pure-English heading stays left, code blocks untouched, streaming reply + settles correctly. diff --git a/package.json b/package.json index a999e79..a1527d6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "rtl-support", "displayName": "RTL Support", "description": "Adds RTL (Right-to-Left) text support for Hebrew, Arabic and Persian to Claude Code in VS Code, Cursor, Antigravity and Kiro", - "version": "0.5.0", + "version": "0.5.1", "publisher": "Digitizers", "license": "MIT", "homepage": "https://github.com/Digitizers/vs-code-rtl-extension#readme", diff --git a/src/content.ts b/src/content.ts index cd77f5e..a1b133d 100644 --- a/src/content.ts +++ b/src/content.ts @@ -294,19 +294,23 @@ const AUTO_RTL_RULES = ` unicode-bidi: plaintext; } -/* Claude's markdown responses (excluding thinking block) */ +/* Claude's markdown responses (excluding thinking block) — container stays RTL + for bubble layout; per-block direction is set by the Auto-mode block walker + via dir attributes (contains-RTL → rtl, pure LTR → ltr) */ .YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) { direction: rtl; - unicode-bidi: plaintext; } -.YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) > :is(p, ul, ol, h1, h2, h3, h4, blockquote), -.YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) > :is(ul, ol) li { +.YBYrtl [class*="root_"] :is(p, li, h1, h2, h3, h4, h5, h6, blockquote)[dir="rtl"] { + direction: rtl; text-align: right; + unicode-bidi: isolate; } -.YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) a { - unicode-bidi: plaintext; +.YBYrtl [class*="root_"] :is(p, li, h1, h2, h3, h4, h5, h6, blockquote)[dir="ltr"] { + direction: ltr; + text-align: left; + unicode-bidi: isolate; } /* Prompt input container — no .YBYrtl ancestor in Auto mode, use #root @@ -906,6 +910,45 @@ export const RTL_AUTO_JS_CODE = ` }, 50); }).observe(scanRoot, { childList: true, subtree: true, characterData: true }); })(); + +/* Per-Block Direction — sets dir="rtl"/"ltr" on markdown blocks inside .YBYrtl + bubbles by presence of RTL characters, so mixed lines read right-aligned + while pure-English blocks stay natural LTR. CSS keys off the dir attribute. */ +(function() { + var RTL = /[\\u0590-\\u05FF\\u0600-\\u06FF\\u0750-\\u077F\\uFB50-\\uFDFF\\uFE70-\\uFEFE]/; + var BLOCK_SEL = 'p,li,h1,h2,h3,h4,h5,h6,blockquote'; + var SKIP_SEL = '[class*="codeBlockWrapper_"],pre,code,[class*="toolUse_"],[class*="toolResult_"],[class*="thinkingContent_"]'; + + function tagBlocks(bubble) { + var els = bubble.querySelectorAll(BLOCK_SEL); + for (var i = 0; i < els.length; i++) { + var el = els[i]; + if (el.closest && el.closest(SKIP_SEL)) continue; + var want = RTL.test(el.textContent || '') ? 'rtl' : 'ltr'; + if (el.getAttribute('dir') !== want) el.setAttribute('dir', want); + } + } + + var dirRoot = document.getElementById('root'); + if (!dirRoot) return; + + function scanAll() { + var bubbles = dirRoot.querySelectorAll('.YBYrtl'); + for (var i = 0; i < bubbles.length; i++) tagBlocks(bubbles[i]); + } + + scanAll(); + + /* Debounced watcher — re-tags during streaming and when bubbles gain .YBYrtl */ + var dirTimer = null; + new MutationObserver(function() { + if (dirTimer) return; + dirTimer = setTimeout(function() { + dirTimer = null; + scanAll(); + }, 100); + }).observe(dirRoot, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] }); +})(); ${PERMISSION_RTL_JS} /* End RTL Toggle Button */ `; From ee95d9be830a93beac34f68c794d86e30a7a797a Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Tue, 4 Aug 2026 02:48:35 +0300 Subject: [PATCH 2/8] fix: scope dir walker to markdown roots, cover all protected containers (Codex round-1 P2) The walker tagged block elements bubble-wide, so a

/

  • inside tool UI (toolSummary_/toolBody_ etc.) could receive a native dir attribute, which is not neutralized by the container's LTR direction rules. Blocks are now queried only under [class*="root_"] markdown containers, and the skip selector covers every container the LTR overrides protect. Adds a regression guard test (test/dir-walker-scope.test.cjs) wired into npm test. Co-Authored-By: Claude Opus 4.8 --- ...04-auto-mode-per-block-direction-design.md | 10 +++-- package.json | 2 +- src/content.ts | 24 +++++++--- test/dir-walker-scope.test.cjs | 45 +++++++++++++++++++ 4 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 test/dir-walker-scope.test.cjs diff --git a/docs/superpowers/specs/2026-08-04-auto-mode-per-block-direction-design.md b/docs/superpowers/specs/2026-08-04-auto-mode-per-block-direction-design.md index 52c6d11..969c10e 100644 --- a/docs/superpowers/specs/2026-08-04-auto-mode-per-block-direction-design.md +++ b/docs/superpowers/specs/2026-08-04-auto-mode-per-block-direction-design.md @@ -32,9 +32,13 @@ paragraph containing lots of English still goes right. ### JS (extends the existing Auto-mode observer in `RTL_AUTO_JS_CODE`) - For each `.YBYrtl` bubble, walk block elements — `p`, `li`, `h1`–`h6`, - `blockquote` — inside markdown containers. -- Skip anything inside `pre`, `code`, `[class*="codeBlockWrapper_"]`, tool - output, and thinking blocks (reuse the stripper's skip approach). + `blockquote` — **only inside markdown containers** (`[class*="root_"]`); + tool/thinking/todo UI reuses the same tags and must not be tagged. +- Additionally skip anything inside every container the LTR overrides protect: + `pre`, `code`, `codeBlockWrapper_`, `thinking_`/`thinkingContent_`, + `toolUse_`/`toolSummary_`/`toolBody_`/`toolResult_`/`toolReference_`, + `todoList_`/`todoListContainer_` — a native `dir` attribute on a child is not + neutralized by direction rules on its container. - Set `dir="rtl"` or `dir="ltr"` per the RTL-char test (`/[֐-׿؀-ۿݐ-ݿﭐ-﷿ﹰ-﻾]/`). - Re-scan on mutations, debounced — same pattern as the BiDi stripper — so a diff --git a/package.json b/package.json index a1527d6..d676692 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "build": "node esbuild.mjs --production", "watch": "node esbuild.mjs --watch", "pretest": "tsc -p tsconfig.test.json", - "test": "node test/concurrency.test.cjs", + "test": "node test/concurrency.test.cjs && node test/dir-walker-scope.test.cjs", "package": "npx @vscode/vsce package" }, "devDependencies": { diff --git a/src/content.ts b/src/content.ts index a1b133d..4ccd930 100644 --- a/src/content.ts +++ b/src/content.ts @@ -917,15 +917,25 @@ export const RTL_AUTO_JS_CODE = ` (function() { var RTL = /[\\u0590-\\u05FF\\u0600-\\u06FF\\u0750-\\u077F\\uFB50-\\uFDFF\\uFE70-\\uFEFE]/; var BLOCK_SEL = 'p,li,h1,h2,h3,h4,h5,h6,blockquote'; - var SKIP_SEL = '[class*="codeBlockWrapper_"],pre,code,[class*="toolUse_"],[class*="toolResult_"],[class*="thinkingContent_"]'; + /* Every container the LTR overrides protect — a native dir attribute on a + child is NOT neutralized by direction rules on the container, so the + walker must never tag inside these */ + var SKIP_SEL = '[class*="codeBlockWrapper_"],pre,code,[class*="thinkingContent_"],[class*="thinking_"],[class*="toolUse_"],[class*="toolSummary_"],[class*="toolBody_"],[class*="toolResult_"],[class*="toolReference_"],[class*="todoList_"],[class*="todoListContainer_"]'; function tagBlocks(bubble) { - var els = bubble.querySelectorAll(BLOCK_SEL); - for (var i = 0; i < els.length; i++) { - var el = els[i]; - if (el.closest && el.closest(SKIP_SEL)) continue; - var want = RTL.test(el.textContent || '') ? 'rtl' : 'ltr'; - if (el.getAttribute('dir') !== want) el.setAttribute('dir', want); + /* Only markdown containers hold prose; tool/thinking/todo UI reuses the + same tags and must keep its LTR layout untouched */ + var roots = bubble.querySelectorAll('[class*="root_"]'); + for (var r = 0; r < roots.length; r++) { + var rootEl = roots[r]; + if (rootEl.closest && rootEl.closest(SKIP_SEL)) continue; + var els = rootEl.querySelectorAll(BLOCK_SEL); + for (var i = 0; i < els.length; i++) { + var el = els[i]; + if (el.closest && el.closest(SKIP_SEL)) continue; + var want = RTL.test(el.textContent || '') ? 'rtl' : 'ltr'; + if (el.getAttribute('dir') !== want) el.setAttribute('dir', want); + } } } diff --git a/test/dir-walker-scope.test.cjs b/test/dir-walker-scope.test.cjs new file mode 100644 index 0000000..011c575 --- /dev/null +++ b/test/dir-walker-scope.test.cjs @@ -0,0 +1,45 @@ +/** + * Regression guard for the Auto-mode per-block direction walker + * (Codex round-1 P2, PR #2): the walker must only tag blocks inside + * markdown containers, and its skip selector must cover every container + * the LTR overrides protect — a native dir attribute on a child is not + * neutralized by direction rules on its container. + */ +const fs = require('fs'); +const path = require('path'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'content.ts'), 'utf8'); + +const walker = src.match(/\/\* Per-Block Direction[\s\S]*?\n\}\)\(\);/); +if (!walker) { + console.error('FAIL — per-block direction walker not found in content.ts'); + process.exit(1); +} +const w = walker[0]; + +const failures = []; + +// 1. Walker must query blocks from markdown roots, never bubble-wide. +if (!/querySelectorAll\('\[class\*="root_"\]'\)/.test(w)) { + failures.push('walker no longer scopes its block query under [class*="root_"] markdown containers'); +} + +// 2. Skip selector must include every protected container class. +const skipSel = (w.match(/var SKIP_SEL = '([^']+)'/) || [])[1] || ''; +for (const cls of [ + 'codeBlockWrapper_', 'thinkingContent_', 'thinking_', + 'toolUse_', 'toolSummary_', 'toolBody_', 'toolResult_', 'toolReference_', + 'todoList_', 'todoListContainer_', +]) { + if (!skipSel.includes(cls)) failures.push(`SKIP_SEL is missing protected container "${cls}"`); +} +for (const tag of ['pre', 'code']) { + if (!new RegExp(`(^|,)${tag}(,|$)`).test(skipSel)) failures.push(`SKIP_SEL is missing "${tag}"`); +} + +if (failures.length) { + console.error('FAIL — dir-walker scope regression:'); + for (const f of failures) console.error(' - ' + f); + process.exit(1); +} +console.log('PASS — dir walker scoped to markdown roots, skip list covers all protected containers'); From cd8e8cc4a78ca022528eb7a40dea86a366ff2679 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Tue, 4 Aug 2026 02:58:20 +0300 Subject: [PATCH 3/8] fix: guard per-block dir CSS against thinking blocks (Copilot round-2) Both [dir=rtl]/[dir=ltr] block rules now carry the same :not(thinkingContent_) scoping as the sibling markdown rule, so a stray dir attribute under thinking content can never restyle it. Regression guard extended to assert the guard on both rules. Co-Authored-By: Claude Opus 4.8 --- src/content.ts | 30 +++++++++++++++++++++++++----- test/dir-walker-scope.test.cjs | 11 +++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/content.ts b/src/content.ts index 4ccd930..517b578 100644 --- a/src/content.ts +++ b/src/content.ts @@ -301,13 +301,13 @@ const AUTO_RTL_RULES = ` direction: rtl; } -.YBYrtl [class*="root_"] :is(p, li, h1, h2, h3, h4, h5, h6, blockquote)[dir="rtl"] { +.YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) :is(p, li, h1, h2, h3, h4, h5, h6, blockquote)[dir="rtl"] { direction: rtl; text-align: right; unicode-bidi: isolate; } -.YBYrtl [class*="root_"] :is(p, li, h1, h2, h3, h4, h5, h6, blockquote)[dir="ltr"] { +.YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) :is(p, li, h1, h2, h3, h4, h5, h6, blockquote)[dir="ltr"] { direction: ltr; text-align: left; unicode-bidi: isolate; @@ -949,13 +949,33 @@ export const RTL_AUTO_JS_CODE = ` scanAll(); - /* Debounced watcher — re-tags during streaming and when bubbles gain .YBYrtl */ + /* Debounced watcher — re-tags during streaming and when bubbles gain + .YBYrtl. Only bubbles touched by the mutation batch are re-tagged; + a mutation outside any bubble falls back to a full scan (e.g. a class + change that newly marks a bubble). */ var dirTimer = null; - new MutationObserver(function() { + var pendingBubbles = []; + var pendingFull = false; + + function noteTarget(node) { + if (pendingFull) return; + var el = node.nodeType === 1 ? node : node.parentElement; + var bubble = el && el.closest ? el.closest('.YBYrtl') : null; + if (!bubble) { pendingFull = true; return; } + if (pendingBubbles.indexOf(bubble) === -1) pendingBubbles.push(bubble); + } + + new MutationObserver(function(records) { + for (var i = 0; i < records.length; i++) noteTarget(records[i].target); if (dirTimer) return; dirTimer = setTimeout(function() { dirTimer = null; - scanAll(); + var full = pendingFull; + var bubbles = pendingBubbles; + pendingFull = false; + pendingBubbles = []; + if (full) { scanAll(); return; } + for (var i = 0; i < bubbles.length; i++) tagBlocks(bubbles[i]); }, 100); }).observe(dirRoot, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] }); })(); diff --git a/test/dir-walker-scope.test.cjs b/test/dir-walker-scope.test.cjs index 011c575..57f98e8 100644 --- a/test/dir-walker-scope.test.cjs +++ b/test/dir-walker-scope.test.cjs @@ -37,6 +37,17 @@ for (const tag of ['pre', 'code']) { if (!new RegExp(`(^|,)${tag}(,|$)`).test(skipSel)) failures.push(`SKIP_SEL is missing "${tag}"`); } +// 3. CSS dir rules must keep the thinking-block guard (Copilot round-2, PR #2): +// per-block [dir] styling must never apply under thinkingContent_. +for (const dir of ['rtl', 'ltr']) { + const re = new RegExp( + `\\[class\\*="root_"\\]:not\\(\\[class\\*="thinkingContent_"\\] \\[class\\*="root_"\\]\\) :is\\([^)]*\\)\\[dir="${dir}"\\]` + ); + if (!re.test(src)) { + failures.push(`CSS [dir="${dir}"] block rule lost its thinkingContent_ guard`); + } +} + if (failures.length) { console.error('FAIL — dir-walker scope regression:'); for (const f of failures) console.error(' - ' + f); From b4069455334299f850e1f9d3ebec9288403067d3 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Tue, 4 Aug 2026 02:58:21 +0300 Subject: [PATCH 4/8] chore: sync package-lock version to 0.5.1 (Codex round-2 P2) Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index e277e89..48da0c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rtl-support", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rtl-support", - "version": "0.5.0", + "version": "0.5.1", "license": "MIT", "devDependencies": { "@types/node": "^20.0.0", From e3610997a96d8f40785afcbd022a18ca80a42432 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Tue, 4 Aug 2026 03:02:22 +0300 Subject: [PATCH 5/8] fix: restore inline bidi isolation for links in RTL blocks (Codex round-3 P2) The CSS rewrite dropped the anchor-specific unicode-bidi rule; since unicode-bidi is not inherited, block-level isolation left URL punctuation reorderable by the surrounding RTL context. Anchors inside markdown blocks get unicode-bidi: plaintext again. Regression guard added. Co-Authored-By: Claude Opus 4.8 --- src/content.ts | 6 ++++++ test/dir-walker-scope.test.cjs | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/src/content.ts b/src/content.ts index 517b578..67a9841 100644 --- a/src/content.ts +++ b/src/content.ts @@ -313,6 +313,12 @@ const AUTO_RTL_RULES = ` unicode-bidi: isolate; } +/* Links keep their own bidi run — unicode-bidi is not inherited, so block-level + isolation alone leaves URL punctuation reorderable by the RTL context */ +.YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) a { + unicode-bidi: plaintext; +} + /* Prompt input container — no .YBYrtl ancestor in Auto mode, use #root to keep specificity high enough to win against Claude Code's own rules */ #root [class*="messageInputContainer_"] > * { diff --git a/test/dir-walker-scope.test.cjs b/test/dir-walker-scope.test.cjs index 57f98e8..b41511e 100644 --- a/test/dir-walker-scope.test.cjs +++ b/test/dir-walker-scope.test.cjs @@ -48,6 +48,13 @@ for (const dir of ['rtl', 'ltr']) { } } +// 4. Anchors need their own inline bidi run (Codex round-3 P2, PR #2): +// unicode-bidi is not inherited, so block-level isolation alone leaves +// URL punctuation reorderable by the surrounding RTL context. +if (!/\[class\*="root_"\]:not\(\[class\*="thinkingContent_"\] \[class\*="root_"\]\) a \{\s*\n\s*unicode-bidi: plaintext;/.test(src)) { + failures.push('anchor unicode-bidi rule missing from Auto-mode CSS'); +} + if (failures.length) { console.error('FAIL — dir-walker scope regression:'); for (const f of failures) console.error(' - ' + f); From 88be133024321daaf743c817b4e33fb1a8f61a37 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Tue, 4 Aug 2026 03:06:28 +0300 Subject: [PATCH 6/8] fix: exclude skipped-container text from direction detection (Codex round-4 P2) A code block quoting Hebrew inside an English list item made the item's textContent test RTL and flipped it. Direction now comes only from text nodes outside SKIP_SEL containers (TreeWalker with a fast path when the subtree has no RTL characters at all). Regression guard added. Co-Authored-By: Claude Opus 4.8 --- src/content.ts | 17 ++++++++++++++++- test/dir-walker-scope.test.cjs | 12 +++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/content.ts b/src/content.ts index 67a9841..61e1603 100644 --- a/src/content.ts +++ b/src/content.ts @@ -928,6 +928,21 @@ export const RTL_AUTO_JS_CODE = ` walker must never tag inside these */ var SKIP_SEL = '[class*="codeBlockWrapper_"],pre,code,[class*="thinkingContent_"],[class*="thinking_"],[class*="toolUse_"],[class*="toolSummary_"],[class*="toolBody_"],[class*="toolResult_"],[class*="toolReference_"],[class*="todoList_"],[class*="todoListContainer_"]'; + /* True when the block has RTL text OUTSIDE skipped containers — a code + block quoting Hebrew inside an English list item must not flip it */ + function hasOwnRtl(el) { + if (!RTL.test(el.textContent || '')) return false; /* fast path */ + var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false); + var n; + while ((n = walker.nextNode())) { + if (!RTL.test(n.nodeValue)) continue; + var p = n.parentElement; + if (p && p.closest && p.closest(SKIP_SEL)) continue; + return true; + } + return false; + } + function tagBlocks(bubble) { /* Only markdown containers hold prose; tool/thinking/todo UI reuses the same tags and must keep its LTR layout untouched */ @@ -939,7 +954,7 @@ export const RTL_AUTO_JS_CODE = ` for (var i = 0; i < els.length; i++) { var el = els[i]; if (el.closest && el.closest(SKIP_SEL)) continue; - var want = RTL.test(el.textContent || '') ? 'rtl' : 'ltr'; + var want = hasOwnRtl(el) ? 'rtl' : 'ltr'; if (el.getAttribute('dir') !== want) el.setAttribute('dir', want); } } diff --git a/test/dir-walker-scope.test.cjs b/test/dir-walker-scope.test.cjs index b41511e..377032a 100644 --- a/test/dir-walker-scope.test.cjs +++ b/test/dir-walker-scope.test.cjs @@ -48,7 +48,17 @@ for (const dir of ['rtl', 'ltr']) { } } -// 4. Anchors need their own inline bidi run (Codex round-3 P2, PR #2): +// 4. Direction detection must ignore text inside skipped containers +// (Codex round-4 P2, PR #2): a code block quoting Hebrew inside an +// English list item must not flip the item to RTL. +if (!/function hasOwnRtl/.test(w) || !/createTreeWalker/.test(w)) { + failures.push('walker direction detection no longer excludes skipped-container text (hasOwnRtl/TreeWalker missing)'); +} +if (!/hasOwnRtl\(el\)/.test(w)) { + failures.push('tagBlocks no longer uses skip-aware hasOwnRtl for direction detection'); +} + +// 5. Anchors need their own inline bidi run (Codex round-3 P2, PR #2): // unicode-bidi is not inherited, so block-level isolation alone leaves // URL punctuation reorderable by the surrounding RTL context. if (!/\[class\*="root_"\]:not\(\[class\*="thinkingContent_"\] \[class\*="root_"\]\) a \{\s*\n\s*unicode-bidi: plaintext;/.test(src)) { From 7941df48baa23c89abd71d6f1cd8c70bdf301b77 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Tue, 4 Aug 2026 03:11:38 +0300 Subject: [PATCH 7/8] fix: nested-block ownership + no full-scan fallback in dir walker (Codex round-5 P2s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) hasOwnRtl now only accepts RTL text whose nearest block ancestor is the judged element, so a Hebrew sub-item no longer flips its English parent item (the nested block gets its own dir). 2) The mutation observer never falls back to a full-history scan: mutations outside bubbles are ignored, and new bubbles are detected directly from addedNodes and class changes — unrelated LTR streaming no longer re-walks every prior RTL bubble per debounce tick. Regression guards added for both. Co-Authored-By: Claude Opus 4.8 --- src/content.ts | 58 ++++++++++++++++++++++++---------- test/dir-walker-scope.test.cjs | 17 +++++++++- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/src/content.ts b/src/content.ts index 61e1603..1681d7c 100644 --- a/src/content.ts +++ b/src/content.ts @@ -928,8 +928,10 @@ export const RTL_AUTO_JS_CODE = ` walker must never tag inside these */ var SKIP_SEL = '[class*="codeBlockWrapper_"],pre,code,[class*="thinkingContent_"],[class*="thinking_"],[class*="toolUse_"],[class*="toolSummary_"],[class*="toolBody_"],[class*="toolResult_"],[class*="toolReference_"],[class*="todoList_"],[class*="todoListContainer_"]'; - /* True when the block has RTL text OUTSIDE skipped containers — a code - block quoting Hebrew inside an English list item must not flip it */ + /* True when the block has RTL text of its OWN — outside skipped containers + (a code block quoting Hebrew inside an English list item must not flip + it) and outside nested blocks (a Hebrew sub-item must not flip its + English parent; the nested block gets its own dir) */ function hasOwnRtl(el) { if (!RTL.test(el.textContent || '')) return false; /* fast path */ var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false); @@ -937,7 +939,9 @@ export const RTL_AUTO_JS_CODE = ` while ((n = walker.nextNode())) { if (!RTL.test(n.nodeValue)) continue; var p = n.parentElement; - if (p && p.closest && p.closest(SKIP_SEL)) continue; + if (!p) continue; + if (p.closest && p.closest(SKIP_SEL)) continue; + if (p.closest && p.closest(BLOCK_SEL) !== el) continue; /* owned by a nested block */ return true; } return false; @@ -971,31 +975,53 @@ export const RTL_AUTO_JS_CODE = ` scanAll(); /* Debounced watcher — re-tags during streaming and when bubbles gain - .YBYrtl. Only bubbles touched by the mutation batch are re-tagged; - a mutation outside any bubble falls back to a full scan (e.g. a class - change that newly marks a bubble). */ + .YBYrtl. Only bubbles touched by the mutation batch are re-tagged. + Mutations outside any bubble are ignored (never a full scan — unrelated + LTR streaming must not re-walk the whole chat history); new bubbles are + caught directly from added nodes and class changes. */ var dirTimer = null; var pendingBubbles = []; - var pendingFull = false; - function noteTarget(node) { - if (pendingFull) return; + function addBubble(bubble) { + if (pendingBubbles.indexOf(bubble) === -1) pendingBubbles.push(bubble); + } + + function noteAncestorBubble(node) { var el = node.nodeType === 1 ? node : node.parentElement; var bubble = el && el.closest ? el.closest('.YBYrtl') : null; - if (!bubble) { pendingFull = true; return; } - if (pendingBubbles.indexOf(bubble) === -1) pendingBubbles.push(bubble); + if (bubble) addBubble(bubble); + } + + function noteRecord(rec) { + if (rec.type === 'attributes') { + /* class change: the target itself may have just become a bubble */ + var t = rec.target; + if (t.nodeType === 1 && t.classList && t.classList.contains('YBYrtl')) { addBubble(t); return; } + noteAncestorBubble(t); + return; + } + if (rec.type === 'childList') { + /* added nodes may BE or CONTAIN bubbles not yet under one */ + for (var a = 0; a < rec.addedNodes.length; a++) { + var node = rec.addedNodes[a]; + if (node.nodeType !== 1) continue; + if (node.classList && node.classList.contains('YBYrtl')) addBubble(node); + else if (node.querySelectorAll) { + var inner = node.querySelectorAll('.YBYrtl'); + for (var b = 0; b < inner.length; b++) addBubble(inner[b]); + } + } + } + noteAncestorBubble(rec.target); } new MutationObserver(function(records) { - for (var i = 0; i < records.length; i++) noteTarget(records[i].target); - if (dirTimer) return; + for (var i = 0; i < records.length; i++) noteRecord(records[i]); + if (dirTimer || !pendingBubbles.length) return; dirTimer = setTimeout(function() { dirTimer = null; - var full = pendingFull; var bubbles = pendingBubbles; - pendingFull = false; pendingBubbles = []; - if (full) { scanAll(); return; } for (var i = 0; i < bubbles.length; i++) tagBlocks(bubbles[i]); }, 100); }).observe(dirRoot, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] }); diff --git a/test/dir-walker-scope.test.cjs b/test/dir-walker-scope.test.cjs index 377032a..c89dcc6 100644 --- a/test/dir-walker-scope.test.cjs +++ b/test/dir-walker-scope.test.cjs @@ -58,7 +58,22 @@ if (!/hasOwnRtl\(el\)/.test(w)) { failures.push('tagBlocks no longer uses skip-aware hasOwnRtl for direction detection'); } -// 5. Anchors need their own inline bidi run (Codex round-3 P2, PR #2): +// 5. A nested block's RTL text must not flip its parent (Codex round-5 P2): +// hasOwnRtl only accepts text whose nearest block ancestor is el itself. +if (!/closest\(BLOCK_SEL\) !== el/.test(w)) { + failures.push('hasOwnRtl no longer excludes text owned by nested blocks'); +} + +// 6. The observer must never full-scan on unrelated mutations (Codex round-5 +// P2): pure-English streaming outside bubbles must not re-walk history. +if (/pendingFull/.test(w) || /scanAll\(\)/.test(w.split('Debounced watcher')[1] || '')) { + failures.push('observer regained a full-scan fallback (pendingFull/scanAll in mutation path)'); +} +if (!/addedNodes/.test(w)) { + failures.push('observer no longer inspects addedNodes for new bubbles'); +} + +// 7. Anchors need their own inline bidi run (Codex round-3 P2, PR #2): // unicode-bidi is not inherited, so block-level isolation alone leaves // URL punctuation reorderable by the surrounding RTL context. if (!/\[class\*="root_"\]:not\(\[class\*="thinkingContent_"\] \[class\*="root_"\]\) a \{\s*\n\s*unicode-bidi: plaintext;/.test(src)) { From 916de9c01b1931a5db9f98b7e04aa56a73dea6e3 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Tue, 4 Aug 2026 03:15:03 +0300 Subject: [PATCH 8/8] fix: count loose-list paragraphs toward their li's direction (Codex round-6 P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-5 ownership rule rejected any text whose nearest block ancestor wasn't the judged element, so
  • Hebrew

  • left the li LTR with its marker on the wrong side. Ownership now stops only at independently rendered nested blocks (li, blockquote) — p and headings inside a loose list item count toward the item, whose marker they visually belong to. Co-Authored-By: Claude Opus 4.8 --- src/content.ts | 21 ++++++++++++++++++--- test/dir-walker-scope.test.cjs | 12 ++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/content.ts b/src/content.ts index 1681d7c..8ca0a61 100644 --- a/src/content.ts +++ b/src/content.ts @@ -928,10 +928,25 @@ export const RTL_AUTO_JS_CODE = ` walker must never tag inside these */ var SKIP_SEL = '[class*="codeBlockWrapper_"],pre,code,[class*="thinkingContent_"],[class*="thinking_"],[class*="toolUse_"],[class*="toolSummary_"],[class*="toolBody_"],[class*="toolResult_"],[class*="toolReference_"],[class*="todoList_"],[class*="todoListContainer_"]'; + /* Blocks that render independently (own marker / own alignment) — text + inside them must not influence an ancestor block's direction. p and + headings inside a loose
  • DO count toward the li, + because the list marker belongs to the li. */ + var INDEPENDENT_SEL = 'li,blockquote'; + + function ownsText(el, parent) { + var node = parent; + while (node && node !== el) { + if (node.matches && node.matches(INDEPENDENT_SEL)) return false; + node = node.parentElement; + } + return true; + } + /* True when the block has RTL text of its OWN — outside skipped containers (a code block quoting Hebrew inside an English list item must not flip - it) and outside nested blocks (a Hebrew sub-item must not flip its - English parent; the nested block gets its own dir) */ + it) and not owned by an independently nested block (a Hebrew sub-item + must not flip its English parent; the sub-item gets its own dir) */ function hasOwnRtl(el) { if (!RTL.test(el.textContent || '')) return false; /* fast path */ var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false); @@ -941,7 +956,7 @@ export const RTL_AUTO_JS_CODE = ` var p = n.parentElement; if (!p) continue; if (p.closest && p.closest(SKIP_SEL)) continue; - if (p.closest && p.closest(BLOCK_SEL) !== el) continue; /* owned by a nested block */ + if (!ownsText(el, p)) continue; return true; } return false; diff --git a/test/dir-walker-scope.test.cjs b/test/dir-walker-scope.test.cjs index c89dcc6..bdc6eb2 100644 --- a/test/dir-walker-scope.test.cjs +++ b/test/dir-walker-scope.test.cjs @@ -58,10 +58,14 @@ if (!/hasOwnRtl\(el\)/.test(w)) { failures.push('tagBlocks no longer uses skip-aware hasOwnRtl for direction detection'); } -// 5. A nested block's RTL text must not flip its parent (Codex round-5 P2): -// hasOwnRtl only accepts text whose nearest block ancestor is el itself. -if (!/closest\(BLOCK_SEL\) !== el/.test(w)) { - failures.push('hasOwnRtl no longer excludes text owned by nested blocks'); +// 5. Independently nested blocks' RTL text must not flip the parent, while +// loose-list paragraphs still count toward their li (Codex round-5/6 P2s): +// ownership stops at nested li/blockquote, not at p/headings. +if (!/INDEPENDENT_SEL = 'li,blockquote'/.test(w) || !/function ownsText/.test(w)) { + failures.push('hasOwnRtl lost the independent-nested-block ownership rule (ownsText/INDEPENDENT_SEL)'); +} +if (!/ownsText\(el, p\)/.test(w)) { + failures.push('hasOwnRtl no longer consults ownsText for direction detection'); } // 6. The observer must never full-scan on unrelated mutations (Codex round-5