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..969c10e --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-auto-mode-per-block-direction-design.md @@ -0,0 +1,71 @@ +# 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` — **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 + 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-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", diff --git a/package.json b/package.json index a999e79..d676692 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", @@ -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 cd77f5e..8ca0a61 100644 --- a/src/content.ts +++ b/src/content.ts @@ -294,17 +294,27 @@ 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_"]: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_"]: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; +} + +/* 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; } @@ -906,6 +916,131 @@ 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'; + /* 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_"]'; + + /* 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 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); + var n; + while ((n = walker.nextNode())) { + if (!RTL.test(n.nodeValue)) continue; + var p = n.parentElement; + if (!p) continue; + if (p.closest && p.closest(SKIP_SEL)) continue; + if (!ownsText(el, p)) 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 */ + 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 = hasOwnRtl(el) ? '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. 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 = []; + + 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) 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++) noteRecord(records[i]); + if (dirTimer || !pendingBubbles.length) return; + dirTimer = setTimeout(function() { + dirTimer = null; + var bubbles = pendingBubbles; + pendingBubbles = []; + for (var i = 0; i < bubbles.length; i++) tagBlocks(bubbles[i]); + }, 100); + }).observe(dirRoot, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] }); +})(); ${PERMISSION_RTL_JS} /* End RTL Toggle Button */ `; diff --git a/test/dir-walker-scope.test.cjs b/test/dir-walker-scope.test.cjs new file mode 100644 index 0000000..bdc6eb2 --- /dev/null +++ b/test/dir-walker-scope.test.cjs @@ -0,0 +1,92 @@ +/** + * 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}"`); +} + +// 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`); + } +} + +// 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. 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 +// 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)) { + 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); + process.exit(1); +} +console.log('PASS — dir walker scoped to markdown roots, skip list covers all protected containers');